Loading post
Jul 06, 2026

自分のブログに置いた将棋AIが弱かった。二段の自分が普通に勝てる程度には弱く、しかも「棒銀で来られると毎回負ける」という、初心者向けの戦法にすら耐えられない状態だった。
そこから約2ヶ月、99件の実験をやった。400個の実行記録がディスクに残っている。
直接対局で「強くなった」と証明できたのは、そのうち3件だった。
この記事は、その3件と、落ちた42件と、そもそも測っていなかった54件の全部を書く。読み終えたときに「自分の将棋ソフトを強くするには何をすればいいか」が分かる形にした。
上から読んでもいいし、必要な章に飛んでもいい。実測値はすべて章の中にある。
| やりたいこと | 章 | 実測での効果 |
|---|---|---|
| そもそも正しく測れているか確認したい | 1章 | 同一エンジン同士が160局で56.9%(p=0.040) |
| そもそも動いているか確認したい | 2章 | 応答1〜23msのバグ、定跡却下率9.4% |
| 速くしたい | 3章 | WASM移植で約15倍、+3〜4手 |
| 探索を改良したい | 4章 | 静止探索の改良が768局で69.7% |
| 評価関数を機械学習にしたい | 5章 | 契約ミスで0勝16敗、正しくやって77.1% |
| 定跡を整えたい | 6章 | 駒損 0.53 → 0.25/局 |
| 終盤を強くしたい | 7章 | 詰み発見 148 → 162 |
| 何が効かないか先に知りたい | 8章 | データ量・教師の深さ・特徴量表現、全部落ちた |
| 打率を知りたい | 9章 | 45件測って3件 |
| コードが見たい | 付録 | 主要な実装9本 |
1章が「測り方」なのは意図的です。 測れなければ、他の8章は全部意味を持ちません。この記事で一番伝えたいのはそこです。
「ブラウザで動く」という制約が効いている箇所は都度書きます。ネイティブなら選択肢が変わる部分があるので。
2ヶ月で分かったことを3行にすると、こうなります。
1. 効いたのは「構造的な変更」だけだった。 速度(WASM 15倍)、探索アルゴリズムの改良、学習データの選び方。
2. 効かなかったのは「量を増やす」系だった。 データ量、ネットの表現力、教師の探索の深さ。全部落ちた。
3. 一番危なかったのは、効いていないのに効いたと思い込むことだった。 同一エンジン同士の対局が160局で56.9%(p=0.040)まで振れる。80局の判定は、判定したい効果と同じ大きさのノイズを持っている。
3番目が、この記事を書いている最大の理由です。
サイトには将棋のページが2つある。エンジンはTypeScriptで、1次元の盤配列、make/unmake、Zobristハッシュ + 置換表、negamax + アルファベータ + PVS + 反復深化。教科書どおりの構成。
その上に、V2からV18までの改良が地層のように積まれていた。
3秒考えて、内部ノード約4,000、静止探索ノード約51,000、到達深さ5。 これは、まともなJSエンジンより1〜2桁遅い。後から振り返ると、この「遅さ」こそが最大のレバーだった。
そして地層を眺めると、2つのパターンが見えた。
つまり、**「効くかどうか分からないまま積み上がった機能」と「7ヶ月ぶんの手書きルール」**が出発点だった。
<a id="0"></a>
技術の話に入る前に、作業の進め方を書いておきます。ここには固有の落とし穴があり、後の章の数字がどう作られたかにも関わるからです。
途中から、開発モードをこう変えました。
「全部サブエージェントで、並列でやって。」
最大5体の Claude Code サブエージェントが同時に走ります。
ps aux、tail -f、top で全部見られる途中で検証手順も修正しました。エージェントには当初「旧V18に勝て」と指示していましたが、「V18ではなく現行のV20と戦わせろ」が正しい。変更前のエンジンを v20base として凍結登録し、「現行との直接対局」を標準ゲートにしました。より感度が高く、再現性もあります。
これが採否データの実物です。
| エージェント | 結果 | 詳細 |
|---|---|---|
| 探索技法 | 4件中1件採用 | 継続履歴を採用(固定深さでノード −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件を発見(角換わりの評価スパイクが定跡全体を殺していた件、±900のゲートが必須の取り返しをブロックしていた件、打つ手が定跡から構造的に指せない件) |
| Texel チューニング | ❌ 不採用(道具は保存) | 16個の評価重みを座標降下で最適化 → 48局の直接対局で 17勝14敗17分。有意な改善なし。診断も定量化した:70msの自己対局から採った1,698局面は、評価→結果のシグナルがノイズ以下(定数を予測するよりフィット誤差が悪い)。本物のTexelは数百万局面を使う。K推定ループの無限ヒルクライムのバグも発見・修正 |
| WASM スパイク | ✅ 大当たり | 3章 |
もっともらしい改善案の生存率は、おおよそ3〜4件に1件。 Stockfish界隈の経験則は5〜10件に1件と言われます。却下する仕組みが無ければ、これらは全部本番に入っていました。
PRには自動レビュー(Gemini / Copilot)が付きます。V19のPRでは、9件の指摘のうち5件が誤検知でした。攻撃走査関数のパラメータ意味を読み違えたもので、getLeastAttackerValue(k, target, teban) の第3引数が守備側であることはコードに書いてあります。
一方で2件は本物でした(型付き配列での ply の範囲外アクセス)。修正しました。
自動レビューとの正しい関係は「全部受け入れる」でも「全部無視する」でもなく、1件ずつ独立に検証すること。
この記事の後半に出てくるバグの1つ(修正自体が別のバグを持ち込んだ件、2章)も、レビューが捕まえました。
ここが一番重い教訓です。サブエージェントの報告を、そのまま信じてはいけません。
実際に起きたこと:
assembly/index-halfkp64-rki16.ts(38,288バイト)ではなく assembly/index.ts(36,787バイト)。3週間分のエンジン改良が静かに巻き戻り、本番相手に38.5%まで落ちました。しかもそのエージェントはサイズとハッシュの不一致を見た上で、ツールチェーンの差だと片付けていました。「自分のソースが自分のバイナリを再現するか」を確認していましたが、問うべきは「出荷中のバイナリがこのソースから再現するか」でしたgit diff --stat origin/main HEAD で発見そこで運用をこう固めました。報告を受けたら、マージや転送の前に必ず:
gh pr view <n> # PRが実在するか
git ls-remote origin <branch> # ブランチが push されているか
git diff --stat origin/main HEAD # 差分が意図した範囲か
この3つを毎回叩きます。
そして逆もありました。エージェントが私の指示を無効化して、そちらが正しかったことが1回。私が立てた前提(バンドラがURLを誤って解決している)が実測で偽だと示され、指示ごと却下されました。私の側が間違っていました。
もう1つ。エージェントが私の集計ミスを見つけました。 候補が対局ごとに手番を入れ替わることを、私は最初の1ペアのヘッダだけを見て全体を判断していました。しかも「2通りの方法で確認した」と書きましたが、2つとも同じ誤った前提の上に乗っていたので独立検証になっていませんでした。
この記事で何度も書く「1つの観測から全体を決めつけるな」を、書いている本人がやりました。
公平のため、自分の失敗も書きます。
git checkout しました。 未完成のエンジン改造9ファイルの上でブランチを引き抜いた形です。データは無事でしたが、git commit -a していたら未完成のエンジン改造がドキュメントのPRに混入していました。差分の中身を毎回確認する習慣がなければ見逃していました| やること | 理由 |
|---|---|
| エージェントごとに worktree を分ける | 互いのファイルを踏まない |
| 凍結したベースラインとの直接対局を標準ゲートにする | 「旧版に勝つ」では感度が足りない |
| 報告は検証するまで事実ではない | 存在しないPR、間違ったソースからのビルド |
gh pr view / git ls-remote / git diff --stat を毎回 | 3週間分の巻き戻しを実際に踏んだ |
| レビューbotは1件ずつ独立検証 | 9件中5件が誤検知、2件は本物 |
| 並列作業のCPU需要を計算に入れる | 対局とテストが食い合って40分停止 |
<a id="1"></a>
将棋ソフトを強くする記事なのに、1章が測定の話から始まるのは変に見えるかもしれない。でも順番はこれで正しい。
測れなければ、強くなったかどうか分からない。 そして怖いのは「分からない」ことではなく、**「強くなっていないのに、強くなったと判定してしまう」**ことです。
これは実際に起きます。しかも、驚くほど簡単に。
まずこの実験をしてほしい。
まったく同じバイナリ、同じ重み、同じ設定を、両側に置いて対局させる。 理屈の上では50%になるはずのものです。
私がやった結果:
160局時点: 56.9% 片側 p = 0.040
320局時点: 52.8% [47.3, 58.2]
160局の時点で56.9%、片側p値0.040。 もしこれが「新しい候補 vs 現行エンジン」だったら、「5%水準で有意」として採用されていました。 中身はまったく同じエンジンなのに。
320局まで伸ばすと52.8%に収束しました。上振れだったわけです。
この実験を A/A対照(A/A control) と呼びます。新しい候補を測る前に、必ずこれを取ってください。 「差が無いとき何%になるか」を知らずに読んだ数字は、後から意味を確定できません。
「80局やったから十分だろう」と思っていました。違いました。
勝率の標準誤差は √(0.25/n) です。n=80 なら 5.6ポイント、95%区間は概ね ±11ポイント。つまり「61.9%」という結果の区間は [50.9%, 71.7%] になり、下限が50%をわずか0.9ポイント上回るだけ、ということが普通に起きます。
必要な局数を効果量から逆算するとこうなります。
| 検出したい効果 | 勝率 | 必要な局数(目安) |
|---|---|---|
| +20 Elo | 52.9% | 1,000局超 |
| +35 Elo | 55% | 約380局 |
| +50 Elo | 57% | 約150局 |
| +100 Elo | 64% | 約50局 |
500局を標準にすることを勧めます。 私の環境(14コアのMacBook)では、1,000局が約5時間で回りました。実測してあります。80局で済ませる理由はもうありません。
この記事で一番きれいに出た結果を先に見せます。
同じ3段階の判定を、2つの候補が通りました。局数を増やしながら測っていく設計です。
| 段階 | 局数 | 候補A(評価関数の補間) | 候補B(探索の改良) |
|---|---|---|---|
| 予備 | 56 | 59.8% ✅ | 67.0% ✅ |
| 独立 | 96 | 58.3% ✅ | 61.5% ✅ |
| 本番 | 768 | 51.2% ❌ | 69.7% ✅ |
候補Aは56局と96局では明らかに強く見えました。エンジン同士で59.8%は大きな差です。それが768局まで伸ばしたら 51.2% ——ほぼ五分になり、事前に決めた基準(信頼区間の下限が50%を超えること)を満たさず却下されました。
候補Bは落ちませんでした。768局で 69.7%、95%信頼区間 [66.4%, 72.9%]。**下限が66%**なので、まぐれでは説明できません。
同じ設計が、上振れと本物を選り分けました。 56局で止めていたら、候補Aを採用していたはずです。
段階を分ける利点はもう一つあります。明らかにダメな候補を早く落とせるので、768局を全候補に回さずに済みます。
判定基準は対局を始める前に決めてください。そして、結果を見てから動かさない。
私の場合は「56局で、112点満点中62点以上」(勝ち2点、引き分け1点)という形でした。
この基準に1点足りずに落ちた実験が4件あります。しかも2件は、まったく同じ成績でした。
29勝 3分 24敗 = 61点 (必要 62点)
勝ち越しています。 29勝24敗なのに不採用。1点は0.5局ぶんで、あと1局引き分けていれば通っていた。
4件とも基準を緩めませんでした。「惜しかったから」で1点下げた記録はありません。
窮屈に見えるかもしれませんが、1.3節の候補Aを見た後だと意味が変わります。59.8%が51.2%になる世界で「惜しい61点」を通し始めたら、その上に積む全部が砂になります。
先手・後手のどちらを持つかで勝率は変わります。序盤の選択でも変わる。
なので、候補を先番側と後番側に同数ずつ振り分けてください。
これを怠って痛い目に遭ったことがあります。ある定跡ゲートのA/Bで、p=0.033という「有意」な結果が出ました。手番順を入れ替えて再測定したら、符号が反転しました。差があったのは変更ではなく、先に指す側でした。
実装上は、対局ごとに候補をA側とB側へ交互に配置し、集計時にどちらが候補かをログのヘッダから解決します。生のA/B勝敗をそのまま足すと、値が入れ替わって意味不明になります(私はこれを実際に間違えました)。
これが一番くり返し確認したことです。
具体例1:教師との一致率 教師エンジンの評価との一致率を 87.4% → 96.2% に改善したモデルがあります。オフラインの数字としては大きな改善です。
直接対局での強さは、まったく変わりませんでした。
具体例2:検証損失 候補群の中で検証損失が最良だったモデルを直接対局させたら、6勝21敗(p=0.006)でした。
具体例3:教師への近さ 「手書き評価より2.0〜2.5倍、教師に近い」という品質ゲートを通ったNNUEが、実際の対局では 19.6% で大敗しました。
なぜこうなるのか。アルファベータ探索が評価関数に求めているのは「兄弟局面の順位」であって、「絶対値の正確さ」ではないからです。
平均誤差が405cpあると、候補手同士の典型的な差(100cp未満)を超えてしまい、順位が入れ替わります。一方、手書き評価は絶対値がずれていても自分の中で一貫しているので、単調なスケールのずれはアルファベータには無害です。
静的な指標は「候補を絞る」ためには使えます。「採用を決める」ためには使えません。
測定器そのものが壊れているケースを3種類踏みました。
(a) 序盤の多様性が消える 強制する序盤手数を2手にしたら、結果が急に 2勝5敗7分 に見えました。2手だとほぼ全局が同じ局面から始まり、決定的なエンジンは「実質2〜4局の複製」を再生します。定跡なしの対照実験が同じ設定で負けたことで、変更ではなく設定が原因だと分かりました。
→ 序盤は6手以上、シードを変える。
(b) 時間スケールの偏り 200msの早指しと1秒の本番対局では挙動が違います(詰み探査のオーバーヘッドが過大評価され、深い探索の利得が過小評価される)。実際、ある技法は medium(800ms)で 3勝5敗、hard以上に絞ったら 6勝2分2敗 と符号が反転しました。
→ 最終判定は本番の持ち時間で。
(c) デフォルト値の食い違い 対局スクリプトのデフォルト評価モードが本番と違っていて、しばらく非本番条件で比較していました。
→ 毎回「本番と同じか?」を口に出して確認する。
(d) 構造的にゼロ 定跡ゲートのA/Bを組んだとき、約1,500回の定跡参照で不一致が0件という結果が出ました。両側が同じ局面で同じ判断をするので、差が出る局面に到達しない。「差がない」のではなく「差が出るはずがない」設計でした。
→ ゲートが実際に発火する局面を先に採取して、そこを起点に測る。
500局以上を標準にすると、対局そのものが一番時間を食います。ここには性質の違う2種類の待ちがあります。
削ってはいけないもの:1局の思考時間。
測っているのは「1000msで考えたときの強さ」です。思考を速くしたら、測定対象そのものが変わります。 1局 ≈ 150手 × 1秒 ≈ 数分は、無駄ではなく実験の定義です。
削れるもの:対局同士の直列化。
1つのマッチプロセスは実質1コアしか使いません(両側が同じプロセス内で交互に考えるため)。空いているコアで複数のマッチを並列に走らせられます。
48局を1プロセスで 2.7時間
48局を6並列で 約27分
どの1局の中身も変わっていません。 それぞれ完全な思考時間を得ています。並べ替えたのはキューだけなので、測定は無傷のまま壁時計だけが縮みます。
公平性の議論も、生成の並列化とは別です。時間制限のあるエンジン対局はマシン負荷に敏感ですが、A/Bの両側は同じプロセス内で交互に考えるので、負荷は両者に等しくかかります。並列度をコア数より十分下に保てば(6マッチ + 学習 ≈ 14コア中8)、「片側だけスケジューラに不運だった」は構造的に起きません。
詰みソルバーを独立に再検証していたとき、**「両方のエンジンが詰みを見落とす」**という結果が出ました。焦りました。
原因は、私が作った再現局面の持ち駒が、金1枚足りなかったことでした。その5手詰は金を2枚打つ必要がありました。
詰みの無い局面で「詰みの見落とし」を測っていたわけです。
再現に失敗したとき、最初の容疑者は自分の再現コードです。
これは1.7節の「検証の枠組み自体がバグる」の個人版です。測定器を疑う順番は、まず自分、次に対象。
自己対局の勝率は開発を駆動するための計器であって、合否の判定者ではありません。
77.1%で自己対局に勝ったNNUEを本番に載せたら、二段の自分が普通に勝ちました。 しかも「意味不明な手を指し続ける」という報告つきで。詰みと無関係な位置に歩を打つ、自分から玉を危険地帯へ歩かせる——2秒考えた末に、です。
この経験のあと、合格基準そのものを差し替えました。
自己対局の勝率ではなく、実際の対局棋譜を、やねうら王 depth 18 と突き合わせた悪手率
そしてその81手の棋譜を、永久の回帰テストにしました。 今後どんな変更をしても、必ずこの棋譜を再生して悪手率が上がっていないか確認します。人間が実際に咎めた失敗より価値のあるテストケースはありません。
| やること | 理由 |
|---|---|
| A/A対照を先に取る | 差が無いときの値を知らないと、候補の値を解釈できない |
| 500局以上 | 80局のノイズは、判定したい効果と同じ大きさ |
| 段階的に局数を増やす | 上振れは局数とともに消える |
| 基準を事前に決めて守る | 1点緩めたら、その上の全部が砂になる |
| 手番順を相殺する | 相殺しないと、手番の差を効果と読み違える |
| 静的指標で採用を決めない | 一致率+8.8ptで強さゼロの実例がある |
| 最後は人間が指す | 自己対局77.1%で人間に負けた実例がある |
<a id="2"></a>
この章が、投資対効果で一番大きかった。
探索アルゴリズムを磨く前に、評価関数を学習させる前に、確認すべきことがあります。
その探索は、本当に走っているか?
私の場合、答えは「走っていなかった」でした。しかも3回、別々の形で。
最初の発見です。ユーザーから実際の対局が送られてきました。相掛かりの標準的な進行から:
9. ▲2四飛 飛車で2四を取る
10. ☖4二飛 ?? 飛車先の交換を無視して、自分の飛車を振る
11. ▲2三歩打 歩を打って角に当てる
12. ☖9四歩 ?? 当たりを無視して端歩を突く
飛車先の交換に △2三歩 と受けるのは相掛かりの初歩です。それを見落とすのは異常でした。
決定的な証拠は、思考時間でした。
本番と同じ呼び出し経路で局面を再現するスクリプトを書いたら、こう出ました。
move 10: AI(hard) plays 82->42 (23ms) ← hard の持ち時間は2秒
move 12: AI(hard) plays 93->94 (1ms) ← 1ミリ秒
探索が、一度も走っていませんでした。
犯人は定跡の「リシンク・フォールバック」でした。遅いエンジンの時代(V16)の名残で、局面が定跡に無いとき、それらしい静かな手を即座に返すという仕組みです。1手の静的チェックだけで検証していました。
欠陥が3つ重なっていました。
リシンクは丸ごと削除しました。定跡を抜けたら必ず探索する。同じ局面で、2.0秒の探索の末に △2三歩打 が出るようになりました。
診断のコツ: 数秒の持ち時間がある難易度で、悪い手が即座に返ってきたら、探索ではなく迂回経路を疑う。
これ以降、書いた再現スクリプトはすべて1手ごとの思考時間を印字します。
2つ目です。
定跡には「エンジン自身の静的評価が、その定跡手を嫌ったら却下する」という安全弁が入っていました。定跡データが壊れていたときの保険です。
発想は正しい。しかし閾値がきつすぎて、超人的エンジンで検証済みの正しい定跡手まで却下していました。
実測した却下率:
master 9.4%
hard 6.4%
expert 6.4%
10手に1手、正しい定跡を捨てていた。 しかも捨てたあとは自力の探索に落ちるので、序盤の一貫性が崩れます。
閾値を 90cp → 150cp に緩めた結果:
| 指標 | 修正前 | 修正後 |
|---|---|---|
| 定跡離脱手数 | 7.91手 | 8.66手 |
| 1局あたりの駒損イベント | 0.53 | 0.25 (p=0.024) |
駒損が半分以下になりました。
ここで1章の(d)が効いてきます。最初に組んだA/Bは、約1,500回の定跡参照で不一致0件でした。両側が同じ定跡・同じ閾値なので、差が出る局面に到達しなかった。ゲートが実際に発火する局面を採取し直して、ようやく測れました。
3つ目が一番ひどい。
数週間かけてNNUEを改良し、対局で判定し、本番にデプロイしました。その成果が一度もユーザーに届いていませんでした。
ブラウザで対局すると、AIが妙に弱く、画面の隅に「低速互換モード」と出ている。これは私が数日前に入れた表示で、AIエンジンがWeb Workerで動かず、メインスレッドの軽量な代替エンジンに落ちたときに出ます。
この設計自体は正しい。 Workerが使えない環境でも対局はできるべきです。問題は、落ちたことが静かすぎたこと。
真因は、ブラウザのHTTPキャッシュに、Workerスクリプトの壊れたコピーが残っていたことでした。しかもそのファイルは cache-control: public, max-age=31536000, immutable で配信されていました。
immutable は「このURLの中身は絶対に変わらないので、有効期限内は再検証しなくてよい」という宣言です。内容ハッシュ付きのファイル名を使うビルドでは標準的で、それ自体は正しい。
結果として、一度壊れたコピーがキャッシュに入ると、ブラウザは1年間それを使い続けます。 リロードしても、翌日になっても、再デプロイしても(URLが同じなら)直りません。
決定的だったのは、同じURLを3通りで読み込む実験でした。
| 読み込み方 | 結果 |
|---|---|
| そのままのURL | 失敗(エラーメッセージが空、filename が null) |
同じURL + ?cb=1 | 成功、正常に実行される |
同じバイト列を blob: から | 成功 |
そして fetch(url, {cache:'no-store'}) は 200 / 818バイトの正しい中身を返しました。
サーバーもバイト列もURLも正常。壊れていたのはブラウザ内のコピーだけ。
修復も一発でした。
await fetch(url, { cache: 'reload' }); // 汚染エントリを上書き
new Worker(url); // → 起動した
immutableキャッシュ + 静かに劣化するフォールバック = 恒久的な無言の劣化
片方だけなら平気です。immutable でも壊れ方が目に見えるならユーザーがリロードするし、静かに劣化しても次のリクエストで直るなら一時的な問題で済む。両方揃うと誰も気づきません。
自分のコードベースを調べたら、同じ条件を満たす資産が4つありました。うち3つは既に守られていました。
// ニューラルネット重みの読み込み(既存コード)
const hit = await cache.match(key);
if (buf.byteLength === EXPECTED_BYTES) return buf; // 検証
await cache.delete(key); // 壊れていたら捨てる
// → 取り直す
検証 → 破棄 → 再取得。 この型が入っていなかったのはWorkerのスクリプトだけでした。一番上流のファイルです。
覚えておく価値があるのは2点。
filename === null は「実行される前に死んだ」の合図。 読み込まれてから例外を投げたなら、ちゃんとしたメッセージが出ますfetch が通ることは、そのリソースが使えることを意味しない。 fetch(url, {cache:'no-store'}) はキャッシュを迂回しますが、new Worker(url) は迂回しません。同じURLでも経路が違いますimmutable は外しませんでした。 外すと全訪問者が毎回再検証のコストを払います。まれな破損のために常時課金するのは割に合わない。直す層が違う、というのが結論です。
代わりに自己修復を入れました。
1回目の失敗 → fetch(url, {cache:'reload'}) で汚染エントリを上書き → 再生成
2回目の失敗 → ?__wcb=<n> を付けて別のキャッシュキーで再生成
それでも失敗 → 既存のストームガードで安全に停止
地雷1:キャッシュ回避のパラメータを params と名付けてはいけない。
バンドラの起動スクリプトはこう書かれています。
var e = new URL(location.href).searchParams.get("params");
if (!e && o.hash.startsWith("#params=")) e = decodeURIComponent(o.hash.slice(8));
クエリの params を先に読み、ハッシュはその後です。もしキャッシュ回避のパラメータを params にしていたら、依存チャンクのリストを乗っ取って壊していました。 __wcb にしました。
地雷2:#params= のフラグメントを URL のシリアライザに通し直してはいけない。
% が二重エンコードされ、decodeURIComponent が別の文字列を返します。生のまま再結合する必要があります。
これも書いておきます。同じ質の失敗なので。
キャッシュ修復は await fetch(...) を伴います。クールダウン後の再試行経路では、この await の前に「Workerは使える」というフラグを解除していました。
まさに、この修正が防ごうとしていた「UIが固まる」症状そのものでした。レビューが捕まえました。
直し方は、return のたびに代入を書くのではなく、下ろし忘れが構造的に起きない形にしました。
const rebuildWorker = async (...) => {
respawnPending = true; // 入口で立てる
try { /* 修復して再生成 */ }
finally { respawnPending = false; } // どの経路で抜けても必ず下りる
};
正直に書いておきます。順番に:
],"",null,null] に見えた。これはバンドラが正規に生成しているペイロードだった4回とも、もっともらしい中間の観測に飛びついて、最終状態を確認しませんでした。 「では実際にNNUEで指しているのか」を1回でも見ていれば、3回は防げました。
4つ目。ユーザーから「同じ手順を4回繰り返す」という報告がありました。
原因は、実戦で既に指された局面の履歴が探索に渡っていなかったことです。探索は自分の読み筋の中の繰り返ししか見ておらず、実戦での繰り返しを検出できませんでした。
対局履歴を探索へ事前投入し、rootのTTカットオフを廃止しました。
ループ再現率 6/96 → 1/96
根絶はしていません。 正直に書いておきます。
これは発見のパターンとして価値があります。
例1:成った駒が見えなくなる 「敵陣の飛車・角に+800」という評価項目がありました。駒が成った瞬間に駒種コードが変わるので、この項目がマッチしなくなる。突破された後、エンジンの評価は「ほぼ互角」と言っていました。
例2:棒銀の銀が、成功した瞬間に見えなくなる 棒銀を検知する評価項目が、攻め銀を5〜7段目でしか走査していませんでした。銀が守備側の陣地に入った——つまり攻めが成功したまさにその瞬間——にペナルティが消えました。
例3:同じ形が、逆側にもあった レビューbotが「1〜2段目も走査していない」と指摘してきました。正しかった。
同じ形のバグは、同じ場所(境界)に巣を作ります。 片側の境界でバグを見つけたら、必ず反対側を見てください。
| 確認すること | 見つけ方 |
|---|---|
| 探索が走っているか | 1手ごとの思考時間を印字する。数秒の持ち時間で即答したら迂回経路を疑う |
| 定跡の安全弁が正しい手を捨てていないか | 却下率を実測する(私の場合9.4%だった) |
| 本番で本当にその経路が動いているか | ブラウザで実際に対局し、最終状態を確認する |
| 千日手の履歴が探索に渡っているか | 同一局面の再出現をカウントする |
| 評価項目の走査範囲に穴がないか | 境界を疑う。片側にあれば反対側にもある |
<a id="3"></a>
強くするアイデアの大半は、測ると落ちます(8章で全部並べます)。速度だけは違います。
速くなったかどうかは測れば分かるし、しかも強さを一切変えずに速くすることができます。これは他のどのレバーにも無い性質です。
将棋エンジンは反復深化で読みます。深さ1で読み切り、深さ2で読み切り、深さ3……と繰り返し、時間切れで止める。
1手深く読むごとに、読む量はおよそ2〜3倍に膨らみます(枝の掛け算なので指数的)。裏を返すと、エンジンが2倍速くなれば、だいたい1手深く読めます。
そして1手の深さは、体感でかなり効きます。
出発点のエンジンは、3秒考えて到達深さ5。これは遅すぎました。
いきなり全部移植せず、まず一部だけ移植して測りました。
指し手生成だけを AssemblyScript に移植して perft(合法手の数え上げ)を測定:
| ベンチ | JS | WASM | 倍率 |
|---|---|---|---|
| 初期局面 perft d4 (718k葉) | 798ms | 28.8ms | ×27.7 |
| 持ち駒の多い局面 perft d4 (25.7M葉) | 28,440ms | 894ms | ×31.8 |
perftの数値は全深さでJSと完全一致しました(移植が正しいことの証明)。この実測を根拠に、全体の移植にGOを出しました。
移植は4段階で進めました。そして全段階で「ビット単位の同一性」を要求しました。
| 段階 | 内容 | 検証 |
|---|---|---|
| P1 | 指し手生成、打ち歩詰め判定、Zobristハッシュ | 4,184局面で合法手数・ハッシュ・駒得が100%一致 |
| P2 | 評価関数(整数演算で完全一致) | 評価速度 ×29(10万回:5.3s → 0.18s) |
| P3 | 探索全体(置換表、全枝刈り、継続履歴、千日手) | 固定深さで48/48局面が最善手・評価値・ノード数まで一致 |
| P4 | 本番統合(25KBのwasmをbase64で埋め込み) | 実ブラウザでフォールバックを一時的に無効化して、WASM自身が指していることを行動で証明 |
結果:3秒で深さ11〜12。現行エンジンに10勝0敗。
これがこの章で一番伝えたいことです。
「だいたい合っている」を許すとバグが網をすり抜けます。完全一致を要求すると、全部のバグが網にかかります。 そして一致が取れた瞬間、速度差はそのまま強さに変換されます。
固定深さで探索して、最善手と評価値だけでなくノード数まで一致すれば、それは「探索木が同一である」ことの証明です。もし1箇所でも判定が変われば、アルファベータの枝刈りがずれてノード数が動きます。動かなかったなら、行動は同一です。
移植を本番へ載せたあと、二段の持ち主が master と対局し、「これは間違いでは?」と疑った手を送ってきました。△3五角——自分の飛車に当てる手で、「角を成って歩を取ったほうが良かったのでは」という疑問つきです。
ローカルのやねうら王(depth 17)に聞きました。
| 候補 | 判定 |
|---|---|
| △3五角(AIの手) | 後手 +150。エンジンの第1候補。隠れた継続手(△5七角成)つき |
| △6六角(「歩が取れる」) | −1977。▲8八角で角がタダ(6六は利いていた) |
| △1七角成(「歩を取って成れる」) | −2037。▲2九桂で馬がタダ |
master のAIが超人的エンジンの第1候補と一致し、人間の「改善案」は2つとも角を丸損していました。
自己対局の数字ではない、別種の証拠です。移植が効いていることの確認としては、これ以上のものは望めません。
移植の途中で、本当のボトルネックが見つかりました。
合法手生成が、生成した約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; } // ← ここで初めて判定
// ... 探索 ...
}
これと静止探索の部分ソート(後述)を合わせて、同じ3秒で深さ5→7、内部ノード4千→2万2千になりました。
SIMD は「1つの命令で複数のデータを処理する」仕組みです。通常のCPU命令は「a と b を足す」を1回やりますが、SIMDは「a1+b1, a2+b2, …, a8+b8 を一度に」やります。
WebAssembly には v128 という128ビット幅のレジスタがあり、16ビット整数を8個詰めて一括処理できます。
NNUEの推論は、ほどいてみるとほとんどが積和演算です。「重み × 活性、足し込む」の繰り返し。SIMDの本領そのものです。
| 処理 | スカラー | SIMD | 倍率 |
|---|---|---|---|
| 1回の評価(差分適用込み) | 1191ns | 191ns | 6.2倍 |
| 完全再計算 | 6425ns | 1156ns | 5.6倍 |
| 実際の探索全体 | — | — | 約1.4倍 |
マイクロベンチでは6.2倍でも、探索全体では1.4倍です。探索は評価以外もやっている(指し手生成、枝刈り判定など)ので、評価が6倍速くなっても全体はそこまで伸びません。アムダールの法則そのものです。
それでも1.4倍は大きい。そして重要なのは正しさの保証です。
NNUEの推論は整数演算で組まれています。整数の加算は結合則が成り立つ(足す順番を変えても答えが同じ)ので、SIMDでまとめて足してもスカラーで1個ずつ足しても、ビット単位で同一の結果になります。浮動小数点ではこうはいきません(丸め誤差が順序で変わる)。
実際に同じ局面を両方で探索し、訪問ノード数が1ノードの狂いもなく一致することを確認しました。速くなったが、指す手は1手も変わらない。
ブラウザ上で最大4スレッドが同じ局面を同時に探索します。
Lazy SMP は名前のとおり「怠惰な」並列化です。各スレッドは独立に探索しますが、共有置換表(計算済みの局面の結果を書いておく共有メモ)を通じて結果を共有します。1つのスレッドが「この局面は詰み」と発見すれば、他はそのメモを読んで再計算を省けます。
厳密に仕事を分割するのではなく、探索順序のばらつき + 共有メモの相乗効果で全体が速くなる、という設計です。
ノード数 約3.0倍(4スレッドで理想の4倍には届かないが、共有・同期のオーバーヘッドを考えれば妥当)
強さ 1000ms × 24局で MT 14勝10敗(58.3%、点推定で約 +58 Elo)
正直に言うと n=24 は有意水準にまったく届いていません。 24回のコイン投げで14回表が出るのと同じ程度には偶然起こります。1章の基準では「傾向がありそう」以上のことは言えません。100局以上に伸ばすべきです。
スレッド間で局面を共有するには SharedArrayBuffer が要ります。ブラウザはこれを cross-origin isolation(COOP/COEP という2つのHTTPヘッダで、ページを他サイトから隔離した状態)でしか許可しません。セキュリティ上の理由です。
しかしサイト全体にこのヘッダを付けると、外部から読み込む画像やスクリプトが軒並みブロックされてトップページが壊れます。
なので、ビルド設定で COOP/COEP を将棋ページのパスだけに限定しました。将棋ページだけが隔離環境になり、他のページは無傷です。
終盤の探索が浅くなる問題(7章)に取り組む中で、速度改善には2種類あることがはっきりしました。
「読む手を変えない」側の実例を3つ積みました。
(1) 打つ手の合法性判定を省く
将棋のルールを使った、拍子抜けするほど単純な話です。
持ち駒を打つ手は、盤上の駒を1つも動かさない。だから自玉に新しい王手がかかることは絶対にない。
盤上の駒を動かす手は、その駒の陰にいた敵の飛車・角が自玉を睨む(ピン)可能性があるので判定が要ります。しかし打つ手は味方の駒を「足す」だけです。
したがって、自玉が元々王手されていなければ、打つ手は常に合法——判定を丸ごと省けます。既に王手されている場合だけ、その打つ手が王手を遮る/取るかを従来どおり確認します。
const mover = k.teban;
k.move(te);
// 打つ手(te.from === 0)は味方を足すだけなので自玉を晒せない。
// 元々王手でなければ(!parentInCheck)常に合法 → 判定をスキップ。
if ((te.from !== 0 || parentInCheck) && isKingInCheck(k, mover)) {
k.back(te); continue;
}
終盤は指し手の80〜90%が打つ手なので、この1行で合法性判定が約27%速くなりました。 生成順・手の集合・ノード数は1ビットも変わっていません。
(2) 打つ手の生成で、盤を1回しか舐めない
打つ手の生成は「駒種(7) × 筋(9) × 段(9)」の三重ループで、内側で毎回「そのマスが空か」を盤から読み直していました。 持ち駒が7種類あれば、同じ81マスを7回走査します。
しかし空きマスの集合も二歩の状態も、駒種には依存しません。
// 打つ手の生成に入る前に、盤を1回だけ走査
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;
if ((bits & (1 << dan)) === 0) continue;
持ち駒が5〜21枚の局面で、生成が 15〜27%高速化(中央値約24%)。
WASM側で罠を1つ踏みました。二歩・空きマスの作業テーブルを1つのグローバルで共有すると、打ち歩詰めの判定が生成器を再帰的に呼び戻し、ループの途中でテーブルを上書きします。 JS側は打ち歩詰めが別の生成器を通るので無事でしたが、WASM側は同じ関数に戻ってきます。テーブルを探索の深さごとに持つようにして再帰安全にしました。
移植では「同じロジック」だけでなく「同じ再帰条件下での同じ安全性」まで保たなければならない。
(3) 王手判定を、ビットボードの「準備部分だけ」借りる
再プロファイルしたら、終盤で重いのは王手判定 isKingInCheck でした。1回あたり約820ns、終盤局面のノードあたりコストの約半分。
強豪エンジン(やねうら王など)が速い理由の一つがビットボードです。盤を整数のビットとして持ち、「空きマス」「利き」などを AND/OR/シフトで一括処理します。
しかし正直に測りました。 JavaScript には落とし穴があります。
move() / back() という最も熱い経路に乗る。維持コストが節約を食い潰す可能性があるそこで、全部書き換える前に軽い実験で「そもそも効くのか」を確かめました。 飛び道具(飛車・角・香)を別リストで持つ方式を試作して単体で測ったところ、現行の8方向レイ走査より約2倍遅いという結果でした。
配列のレイ走査は、JSのJITに対して既にかなり最適化されていたわけです。
なので、ビットボードの差分状態を必要としない部分だけを借りました。k.get() というメソッド呼び出し(境界チェック付き)をやめて ban[] を直読みし、マスごとの敵味方判定を手番から1回だけ計算したビットマスクに畳み込む。
const ban = k.ban;
const enemyFlag = teban === SENTE ? GOTE : SENTE;
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;
}
アルゴリズムと利きテーブルはビット単位で不変なので、結果は元と完全に同一です。差分状態を持たないので move() / back() への追加コストはゼロ。
isKingInCheck 820ns → 557ns (約32%減)
ノード全体 300,700ns → 246,900ns (約18%減)
WASM側の perft +8.5% 〜 +9%
固定2秒の到達ノード 13,917 → 14,855 (+6.7%)
教訓: 「ビットボードは速い」は真だが、それは64ビットレジスタと安価な差分更新がある環境(C++)についての主張です。JavaScriptにそのまま持ち込むと維持コストが勝ちを食う。教科書の構造を鵜呑みにせず、自分の環境で単体プロファイルして、割に合う部分だけ拾う。
| やること | 実測 |
|---|---|
| まず一部だけ移植して測る | perft で ×27.7 を確認してから全体へ |
| ビット単位の一致を要求する | 48/48局面でノード数まで一致 |
| 遅延合法性判定 | 深さ5→7、ノード4千→2万2千 |
| SIMD | 評価6.2倍、探索全体1.4倍、指す手は不変 |
| 「読む手を変えない」速度改善を積む | 合法性27%減、生成24%減、王手判定32%減 |
| 教科書の構造は単体で測ってから | ビットボード方式は自環境で2倍遅かった |
<a id="4"></a>
99件の実験のうち、直接対局で証明できた強化は3件でした。そのうち最大のものが、この章の話です。
768局 69.7% [66.4%, 72.9%] ≈ +145 Elo
しかも変更内容は、静止探索の王手回避という一点だけでした。
まず土台を確認します。アルファベータ探索は「候補手を1手指してみて、その先を読んで、評価関数に点を聞く」を再帰的にやります。
**「手の良し悪し」= 「その手が導く局面のスコアの比較」**です。評価関数は審判で、探索は候補それぞれの未来を審判の前に並べる案内役。
1章で書いた「探索が欲しいのは順位であって絶対値ではない」は、この図から直接出てきます。
静止探索(quiescence search) は、通常の探索の末端でやる追加の探索です。
なぜ必要か。深さ4で読み切って「+300」と評価したとして、もしその局面で相手が飛車を取れる状態だったら、その評価は嘘です。駒の取り合いが途中の局面を静的に評価すると、必ず間違えます。
そこで末端では「取る手・成る手だけを、決着がつくまで読み続ける」。これが静止探索です。
問題は、王手されている局面です。 通常の静止探索は「取る手」しか読みません。しかし王手されているなら、回避しなければ話が始まらない。回避手には「王が逃げる」「合駒を打つ」など、取る手ではないものが多く含まれます。
ここを改良したのが今回の勝ちでした。王手されている局面では、回避手を直接生成して読む。
段階 局数 候補の成績 スコア
予備 56 35勝 5分 16敗 66.96%
独立 96 56勝 6分 34敗 61.46%
本番 768 520勝 31分 217敗 69.73% [66.4, 72.9]
768局で69.7%、信頼区間の下限が66.4%。 局数を増やしても落ちませんでした。
比較のため、同じ3段階を通った評価関数側の候補は 59.8% → 58.3% → 51.2% で落ちています(1章)。同じ設計が、まぐれと本物を選り分けました。
移植前のエンジンには、教科書的な枝刈りが「機能はあるがOFF」の状態で積まれていました。系統的にA/Bして、効くものをONにしました。
| 技法 | 何をするか |
|---|---|
| Null move pruning | 「1手パスしても相手に勝てるなら、この局面は良い」と仮定して枝を刈る |
| LMR (Late Move Reduction) | 手の並べ替えで後ろに来た手は「たぶん悪い」ので浅く読む |
| Futility pruning | 末端付近で、静的評価 + 余裕 でも alpha に届かない静かな手を飛ばす |
| Aspiration window | 前回の評価値の周辺だけを探索窓にして、外れたら広げる |
| Killer / History / Countermove | 過去に枝刈りを起こした手を優先的に読む(手の並べ替え) |
| SEE (Static Exchange Evaluation) | 駒の取り合いの損得を静的に見積もり、明らかな損な取りを飛ばす |
| IID (Internal Iterative Deepening) | 置換表に手が無いとき、浅く読んで並べ替えの種を作る |
| 静止探索の部分ソート | 静止探索が読むのは取る手・成る手だけなので、それだけを前に寄せて部分ソートする |
最後のものは地味ですが効きました。それまで全部の手を採点してソートしていたのを、読む手だけソートに変えただけです。
正直に書きます。「読む手を変える」系の改良は、私の場合2回とも落ちました。
アイデア: 浅くしか読めないなら、せめて連続王手の一本道は深く読む。詰みや寄せは連続王手で決まることが多いので、そこだけ延長すれば終盤の見落としが減るはず。
論理は正しく見えました。1経路あたりの延長回数に予算まで付けて実装しました。
結果:自己対局のA/Bに勝てず、不採用。
理由は後から腑に落ちました。王手延長は「読む手を変える」変更です。 ある筋を1手深く読むということは、別の筋が1手浅くなるということ。終盤全体が速くなるわけではないので、助かる局面と損する局面が相殺して、合計で中立になりました。
既存のLMPは打つ手を頑なに除外しています。将棋では打つ手が寄せの急所になることが多いからです。
そこで最も価値の低い打つ手だけに触りました。浅い深さで、十分な数の手を読んだ後、敵玉から遠い歩・香の打つ手だけを刈る(金銀桂角飛は絶対に刈らない、敵玉の近くも刈らない)。閾値も距離ガードも既存LMPより一段厳しく。
結果:3シードで 16勝24敗8分。1シードも勝てず、明確な負け。
理由は王手延長と同じ形でした。「敵玉から遠い歩・香の打つ手」は、将棋の終盤では必ずしも無意味ではない。 後の成りを見据えた垂れ歩、布石としての捨て駒、逃げ道封鎖——遠く見える手が寄せの一部であることが多い。得と損は相殺せず、損が勝ちました。
不採用。本番の挙動はビット単位で不変のままにし、コードとA/Bフラグは「試して負けた」記録として残しました。
「他の手より突出して良い手」を1手深く読む技法です。実装と正当性検証(perft、固定深さでのビット一致)は完了し、CIも通りました。
しかし固定深さでノード数が約2倍(17.0M → 35.2M)になり、本番の1000msでは到達深さがむしろ下がりました(9.70 → 9.53)。
パラメータを振ると単調な用量反応が出ます。発火させるほど深さを失い、深さが中立になる設定はどれも「実質発火していない」設定でした。除外探索を安くする試みは2つとも逆効果——判定が雑になると「singular」と誤判定する頻度が上がり、余計な延長のコストが判定の節約を上回りました。
将棋は持ち駒があるので指し手が多く、「他の全部を確かめる」判定が高くつきます。 1秒という持ち時間では割に合わない、というのが現時点の読みです。未ゲートのまま保留にしてあります。
失敗した実験から出た、この章で一番面白い発見です。
やろうとしたこと: エンジンは反復深化で読み、時間切れで中断した回の結果は丸ごと捨てられます。 計測したら:
最後に開始した反復が破棄された探索 95.8%
その破棄された反復に費やした時間 平均 713ms(予算1000msの7割)
**「捨てるなら、最初から始めなければいい」**と考えました。終わりそうにない反復はスキップして早く指す。棋力は変わらず待ち時間だけ減るはず。
実測:
待ち時間 938.6ms → 633.4ms (−32.5%)
棋力 600局で 44.2% [40.3, 48.2] ≈ −40 Elo
待ち時間を3分の1減らしたら、40 Elo失いました。 区間の**上限が48.2%**なので、「変わらない」ではなく「有意に弱い」。
なぜか。置換表は、対局中クリアされません。
捨てられた反復が書き込んだ深い読みの結果は、次の手を考えるときに読み出されます。 エンジンは自分の持ち時間で、次の手の先読みを済ませていました。
検証のため、両側とも毎手置換表をクリアして同じ対局をやり直しました。
通常(持ち越しあり) 44.2%
毎手クリア 54.7% [47.0, 62.2]
赤字が消えました(z = 2.34, p ≈ 0.019)。 負けた原因は探索が浅くなったことではなく、持ち越しを壊したことでした。差し引き73 Elo相当の価値が、あの「無駄に見えた713ms」に入っていました。
この記事を読んでいる人へ: 「どうせ捨てる反復を省く」という最適化は、このエンジンでは前提から間違いです。回収すべき遊休時間が存在しません。
そして逆向きの示唆もあります。手をまたぐ持ち越しにこれだけの価値があるなら、置換表のサイズを増やすと効く可能性がある(現状 2^20 = 約105万エントリ)。1局の累積ユニーク局面数がこれを超えていれば、73 Elo分の価値を持つエントリが最初に追い出されていることになります。これはまだ測っていません。
相手が考えている間、AIは何もしていません。 これは無料の計算資源です。
AIが指した瞬間から、相手が見ている局面の続きを読み始めます。 対局中ずっと保持される置換表を温めておき、人間が指したとき、本番の探索は温まった置換表を引いて同じ時間で深く読めます。
実装の要は「同期的な探索を、どうやって中断可能にするか」です。WASMの探索は同期呼び出しなので、素朴に長く回すとWorkerがメッセージに反応しなくなります。
答えは、200msの短いスライスを setTimeout(0) で繋ぐループです。
// なぜスライスに分けるのか:
// - WASMの探索は同期的で、1回長く呼ぶとWorkerが受信メッセージ(次のbestMove,
// clearTT など)に対して無反応になる。そこで短いスライス(既定200ms)を1回走らせ、
// setTimeout(0) でイベントループへ戻し、次のスライスを積む。
// スライス中に届いたメッセージはキューに積まれたスライスより先に処理されるので、
// onmessage から stop() を呼べば最大1スライス分の遅延で確実に止まる。
安全装置を4つ付けました。
stop()/start() のたびに増やし、古いタイマーが前のセッションの探索を走らせないようにするvisibilitychange を中継する効果:平均探索深さ 9.00 → 9.35(+0.35手)。 序盤では最大 +2手(置換表が最も温まりやすいので)。
| やること | 実測 |
|---|---|
| 静止探索の王手回避 | 768局 69.7% — 全実験で最大の強化 |
| 教科書的な枝刈りを系統的にONにする | 深さ5→7 |
| 静止探索の部分ソート | 読む手だけソートする |
| ponder | +0.35手、序盤で最大+2手 |
| A/Bで中立。不採用 | |
| 16勝24敗8分。不採用 | |
| ベンチは速いが実対局で負け | |
| singular extension | 1秒では判定コストが高すぎる。保留 |
| 置換表の持ち越しを壊さない | 壊すと −40 Elo。持ち越しには73 Elo相当の価値 |
<a id="5"></a>
一番長い章です。一番派手に失敗し、一番大きく成功した領域でもあります。
先に地図を出します。
よくある誤解から解きます。評価関数は「この手が良い/悪い」を直接judgeしません。 やることは1つだけです。
盤面を見せると、数字を1つ返す。
+250 なら「先手が歩2枚半ぶん良い」、−1200 なら「後手が勝勢」、0 なら「互角」。
では手の良し悪しは誰が決めるのか。探索です(4章の図)。
手書き評価は、人間が読めるルールの合計です。
score = 駒得; // 歩=100, 飛=1040, ... の差の合計
score += 駒割表のボーナス; // 「この駒がこのマスにあれば +N」、局面進行度で重み付け
score += 玉の安全度; // 玉の周りの金銀の数
score += 囲いの形; // 矢倉・美濃・穴熊のパターンマッチ
score += 飛車先の受け;
score += 棒銀への圧力;
score += 大駒の働き;
return score; // 例: +250
ニューラルネット版は、同じ問い(盤面 → 数字)に答えるまったく別の機械です。開けてみると58万個の匿名の数字が並んでいて、「玉の安全度」というラベルの行はどこにもありません。
しかし探索から見れば、この2つは完全に交換可能です。探索は「局面を渡すと数字を返す箱」しか要求していない。中身を聞きません。
この章のA/Bは、文字どおり「審判だけ入れ替えて、同じ探索で対局させる」ことです。
答え:「正しい」は説明可能性ではなく、結果で測るから。
身近な先例があります。強い人の直感です。二段の人が局面を一目見て「先手が良い」と感じる。しかしその判断を完全には言語化できない。後付けの説明(「駒得だから」「玉が薄いから」)は、頭の中で実際に起きた計算を記述していません。それでも判断はだいたい当たる。
ニューラルネットは、この言語化を経由しない直感を数字の塊として実装したものです。手書き評価が「誰かが言葉にできた知識」しか持てないのに対して、ネットは100万件の教師の判断からパターンを直接吸収します。
読めないものは、読むのではなく、検査します。
1章の「最後の審判は対局」は、読めないものは挙動でしか監査できないことの系でもあります。
そして裏面も同じ場所にあります。ネットが間違えているとき、その理由も同じくらい読めません。 手書き評価なら「棒銀の項目が4段目で切れている」と特定して1行直せます。ネットの誤りはデータを変えて学習し直すことでしか直せない。19.6%の敗北から「教師データを増やす」という一見遠回りな道のりが、実はニューラルネットが提供する唯一の修理手順でした。
NNUE(Efficiently Updatable Neural Network) は、実は将棋発の発明です。2018年に将棋プログラマの那須悠氏が考案し、やねうら王系で普及し、2020年にチェス最強の Stockfish が採用して世界標準になりました。
本質は名前にあります。1手指しても盤上の事実は2〜4駒ぶんしか変わらないので、第1層の活性は再計算せず差分更新できる。これによって、アルファベータが要求する毎秒数十万回の評価にCPUが追いつきます。
ネットの構造は小さく、全文引用できます。
class DistillNet(nn.Module):
H1 = 256 # 第1層の幅。NNUE伝統の値で、推論速度を支配する
H2 = 32 # 第2層の幅。一気に1/8へ絞るのがNNUEの特徴
def __init__(self):
super().__init__()
# (1) 盤面の入力層。「7六に先手の歩」のような2,268個の「事実」それぞれに
# 256個の数字の行(重みベクトル)を割り当てる。局面の評価は、
# 成立している事実の行を全部足すことから始まる。
# EmbeddingBag(mode="sum") がこの「引いて足す」を1命令でやる。
self.board = nn.EmbeddingBag(BOARD_FEATS + 1, self.H1, mode="sum", padding_idx=PAD_IDX)
# (2) 持ち駒の入力層: 14種類の枚数 → 同じ256次元
self.hand = nn.Linear(HAND_FEATS, self.H1)
# (3)(4) 縮約層: 256 → 32 → 1個の数字(評価値)
self.l2 = nn.Linear(self.H1, self.H2)
self.l3 = nn.Linear(self.H2, 1)
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]に切る
h2 = torch.clamp(self.l2(h1), 0.0, 1.0)
return self.l3(h2).squeeze(-1) # 出力1つ ≈ cp / 600
パラメータのほぼ全部が表(1)にあります——2,268行 × 256列 ≈ 58万個。そのどこにも「玉の安全度」とは書かれていません。
ClippedReLU は趣味ではなく実用上の選択です。活性が [0,1] に固定されるので、学習した float の重みが int16 への量子化に耐えます(WASM側は整数演算で動くため)。
loss.backward() で何が起きているか学習ループの核心はこれだけです。
out = model(b, h) # ミニバッチを採点させる
loss = F.mse_loss(torch.sigmoid(out), t) # 教師のスコアとのズレ
opt.zero_grad()
loss.backward() # 逆伝播: 58万個のダイヤル全部について「どちらへ回せば
opt.step() # 損失が減るか」を計算し、少しずつ回す
「損失が減る方向へ重みを調整する」——誰がその方向を知っているのか?
原理は素朴です。58万個の重みそれぞれについて「これを少し上げたら損失は上がるか下がるか、どのくらい急か」を問う。 その傾きが勾配で、あとは各ダイヤルを損失が減る向きに一歩回す。
new_weight = current_weight - learning_rate * slope
learning_rate は「一歩の大きさ」。大きすぎると行き過ぎて発散し、小さすぎると辿り着かない。
自明でないのは傾きの計算です。素朴にやると、1つ動かして全体を再評価、を58万回。逆伝播は微分の連鎖律を使って、誤差の責任を層を遡って配り、全部の重みの傾きを1回の逆向きパスで同時に計算します。
出力の誤差: 「評価が0.3低く出た」
↓ l3 の重みに責任配分 「最終判断でこの特徴を軽く見すぎた」
↓ l2 の重みに責任配分 「その特徴が弱く出たのはこの縮約のせい」
↓ 盤面テーブルに責任配分 「『7六の歩』の行の数字がそもそも小さすぎた」
イメージは霧の中を目隠しで下る人です。地図(正解の重み)は誰にも見えないが、足元の傾きは毎歩計算できる。一番急な下り方向へ小さく一歩。ミニバッチ1つが一歩で、100万局面 × 40エポックなら十万歩以上。
「val_mae が437cpまで下がった」は、この下山が標高437cpの谷に着いたという意味です。
学習は一度きり、オフラインです。 MacのGPU(MPS)で、100万局面 × 40エポックが7〜10分。対局のたびに学習することはありません。
教師データの作り方です。全部ローカル、費用ゼロ。
100万局面へ向けた残り時間を見積もったら約11時間でした。「GPUを足せば?」——調べたら答えは別の場所にありました。
node(生成ドライバ) CPU 100% ← 14コアのうち1コアが張り付き
やねうら王 × 8プロセス CPU 約0% ← 全部アイドル
マシン全体 81% idle
パイプラインは (1) 自己対局で局面を作る と (2) やねうら王で採点する を交互にやります。チャンクごとの実測は 生成102秒 vs 採点1.5秒。採点側は暇で、ボトルネックは生成側でした。GPUは無関係です。
なぜ生成が遅いか。Node.js はシングルスレッドなので1プロセス=1コア、しかも指し手選択がJS版のエンジンのままでした。本番用に作った15倍速いWASMが、すぐそこにあったのに。
2段階で直しました。
チャンク生成: 102秒 → 27秒
総スループット: 約8局面/秒 → 約110局面/秒(14倍)
残り時間: 11時間 → 45分
コアの使われ方が面白い部分です。 全27プロセス(3ドライバ × 各8エンジン = 24エンジン)なのに、平均で忙しいのは3〜4コアだけ。24エンジンはバースト作業者で、1,000局面が溜まるまで寝ていて、1.5秒で採点して、また寝る。常時忙しいのは局面を作る3つのNodeコアだけ。
常時忙しい役割はコア数に合わせ、バースト役割は多めに用意する。
教訓2つ。 待ちが長いと感じたらまず ps で誰が忙しいかを見る。そして一度作った速度資産は思わぬ所で再利用できる——ブラウザの対局相手を強くするために作ったWASMが、機械学習の教師データ工場を14倍にしました。
524万局面を2台のMacで生成したとき、紙の上では見えない罠が次々出ました。同じことをやる人が必ず踏むので、数字ごと残します。
(1) depth 12 にしたら、ボトルネックが「移動」した。 depth 8 のときは生成がボトルネックで採点は一瞬でした。depth 12 にすると採点が重くなり、生成と採点がほぼ半々(約27秒 / 28秒)になりました。設定を変えるとどちらが重いかが変わるので、遅くなったら毎回測り直す必要があります。
(2) 分散の重複は、シードで自動的に回避された。
2台で同じコードを走らせると「同じ局面を二重生成するのでは」と心配になります。不要でした。 生成器のRNGシードが time ^ プロセスID なので、マシンもプロセスも衝突しません。マシンごとに範囲を割り振るような調整は一切不要でした。局面生成が embarrassingly parallel(各局面が独立)であることの強みです。
(3) オーバーサブスクリプションの罠。
「プロセスを増やせば速い」は素朴すぎて逆効果です。2台目は12コアの旧世代Macで、--engines 8(4ドライバ × 8エンジン = 32エンジン)で起動したら、32エンジンが12コアを奪い合って生成が4倍遅くなりました(117秒)。
コア数より大幅に多いプロセスを立てると、OSがそれらを互いに叩きつけ合って全体が詰まります。--engines 2 に落として解消。さらに観察すると生成は1ドライバ=1コアで、8コアが遊んでいました。 ドライバを 4 → 8 に増やして遊休コアを埋め、総スループットを上げました。
「プロセスが多いほど速い」ではありません。 コア数と、各プロセスの役割(常時忙しい生成 vs バースト的な採点)を見て割り当てる。
(4) CPU と GPU の使い分け。 生成(探索・採点)はCPU、学習はGPU(MacのMPS)。 探索は「枝分かれが多く予測しにくい」作業でCPU向き、学習は「同じ計算を巨大なバッチに一気に」でGPU向きです。同じ「重い計算」でも、形によって向くハードが違います。
(5) 同時書き込みでファイルが壊れた事故。
同じ生成コマンドを二重起動してしまい、2プロセスが同じ出力ファイルに同時に追記しました。行が交錯して行の途中で壊れ、git grep をかけると Binary file ... matches が返るまでになりました(不正なバイトが混ざってテキストと判定されなくなったときの警告)。
同じファイルへの並行追記は、ほぼ確実に壊れます。 幸い壊れたのは少量だったので削除してやり直しました。
並列で書くなら、プロセスごとに自分のファイルへ書いて、最後に連結する。
(6) GPUは1つ ― 異なる資源をパイプライン化する。
「並列化」という言葉に埋まった思い込みを剥がす話です。Apple Silicon(M4 Pro)はチップに内蔵されたGPUが1つです。「GPUコア20個」と聞くと20台のGPUを想像しますが、1つのGPUの中に20コアという意味です(system_profiler SPDisplaysDataType で Metal デバイスが1つしか出ないことで確認できます)。
なので学習を2つ同時に走らせても、唯一のGPUを取り合うだけで速くなりません。
しかし逃げ道があります。 A/B対局はCPU作業(14コアで局面を読む)、学習はGPU作業——別の資源を使うので、同時に走らせても競合しません。
そこで、素朴な直列(base学習 → rank学習 → 評価 → 量子化 → A/B)をやめてパイプライン化しました。base の学習が終わったら、そのA/B対局(CPUで約2時間)を回しながら、空いたGPUで裏でrankの学習を並行させる。GPUの遊休時間をCPUの対局で埋めたわけです。全体が約4時間 → 約3時間になりました。
「並列化」は「同じ作業をたくさん並べる」だけではありません。異なる資源(CPU / GPU)にまたがる作業を重ねるパイプライン化も同じくらい強力です。 ボトルネック(唯一のGPU、削れない対局時間)を特定し、その裏で遊んでいる別の資源に別の仕事を流す。(3)が「同じ資源を奪い合わせるな」なら、(6)は「別の資源を遊ばせるな」——同じコインの裏表です。
品質ゲートは通りました。1万局面の検証セットで「教師にどれだけ近いか」:
| 評価器 | 平均誤差 | 中央値 |
|---|---|---|
| 蒸留NNUE(float) | 405cp | 263cp |
| 手書き評価(最良の線形較正つき) | 800cp | 648cp |
| 常に0を返す基準 | 1662cp | 1962cp |
手書き評価より2.0〜2.5倍、教師に近い。
実対局は19.6%で大敗でした。
推論はtorch/TS/WASMで300局面ビット一致、速度も問題なし(同時間で同ノード数)。実装は正しく、モデルが負けました。
診断が本題です。「教師への近さ」は棋力の予測子として役に立ちませんでした。
1章で書いたとおり、アルファベータが必要とするのは兄弟局面の順位です。ネットの405cpの誤差は、候補手同士の典型的な差(100cp未満)より大きい。順位が壊れます。
加えて、探索のマージン定数(aspiration/futility/delta)が手書き評価のスケールに合わせて調整されていました。手書き評価は真のcpの約3.7倍のスケールだったので、真のcpを出すネットに対してはマージンが実質3.7倍甘いことになります。
敗因の仮説は複数ありました。同時に全部直したら、どれが効いたか分かりません。 なので1つずつ単離しました。
仮説「マージンのスケール不整合が主犯」を測るため、他の変数を1つも動かさずに出力スケールだけを変える機能を追加しました。
export function nnueEvaluateCp(): i32 {
const outQ = nnueEnabled && !nnueForceFull ? nnueEvaluateFast() : nnueEvaluate();
// 出力の再スケール(numer/denom、既定1/1)を cp 変換と同じ i64 除算に畳み込み、
// 切り捨てが1回だけになるようにする。1/1 なら trunc(out_q * K / 8128) とビット一致。
let cp = (<i64>outQ * <i64>nnueScaleK * <i64>nnueOutNumer) / (<i64>8128 * <i64>nnueOutDenom);
// ...
}
既定の1/1は従来と完全にビット一致(ゼロ回帰の保証)。
同じ重み、同じ条件、37/10の較正だけを適用して28局:
| 条件 | 較正なし | 37/10 較正 |
|---|---|---|
| 合計 | 5.5/28 (19.6%) | 2.5/28 (8.9%) |
回復しないどころか、悪化しました(−10.7pt)。仮説は棄却。
解釈:較正なしの「3.7倍甘いマージン」は、浅い枝刈りとして保険になっていた——ノイズの多い評価(405cp)を余分な探索で検算していたわけです。マージンを本来の強さに戻すと、評価のノイズが枝刈りの判断に直撃します。
筋の通った機構的仮説でも、A/Bで死にます。 3つの仮説を一度に「修正」していたら、どれが効いたか永久に分からなかった。
診断が「教師データの質と量が本当の戦場」を指したので、そこへ投資しました。
変えたのは2つ。
(1) 局面数 10万 → 100万
(2) --balance オプション: |cp| > 1200 の決着局面を確率的に間引いて、互角に近い局面の比率を上げる。第1ラウンドのデータは60%超が |cp| > 1000 で、一方的な終盤に偏り、アルファベータが最も必要とする微妙な中盤の差が薄かった。
(3) ランキング損失: 第1ラウンドの教訓「探索は順位が欲しい」を、損失関数の形に直接書きました。
if args.loss == "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)
if ia.numel() > 0:
rank_loss = F.relu(rank_margin_logit - (out[ia] - out[ib])).mean()
# 教師が「Aが良い」と言っているのに、ネットがAをBより
# マージン分だけ上に並べていないペアだけを罰する。
# 絶対値の正確さは要求しない。順序だけ。
loss = loss + args.rank_weight * rank_loss
霧の山の比喩で言うと、ランキング損失は山の形そのものを変えます。 スコア誤差だけを罰する山では、兄弟局面の順位が逆な場所は「下山が無視する浅い窪み」です。ランキング項はそこを深い谷に彫るので、同じ下山アルゴリズムが「順序が正しい低地」へ歩くようになります。どこへ着くかは、何を罰するかで決まります。
| 条件 | 10万 | 30万-base | 30万-rank |
|---|---|---|---|
| 合計 | 5.5/28 (19.6%) | 8/28 (28.6%) | 9/28 (32.1%) |
base でも +9pt。 学習方法を1つも変えず、3倍のデータと間引きだけで改善しました。「データがボトルネック」という診断の直接確認です。
そして持ち時間による非対称が現れました。rank は 1000ms で手書き評価と**互角(50%)**に届き、200msの早指しでは沈む。base は逆。解釈は理論と合います——深く読むほど順位の正確さが複利で効き、浅い探索では大きなスコアの較正が枝刈り判断に直結する。本番は1〜5秒。どちらに賭けるかは明らかでした。
| モデル | MAE | pair_acc | 互角帯(0〜300)のMAE |
|---|---|---|---|
| run100k | 558.9cp | 0.8370 | 407cp |
| run300k-rank | 645.3cp | 0.8519 | 287cp |
| run1m-base | 458.7cp | 0.8727 | 258cp |
| run1m-rank10 | 699.4cp | 0.8613 | 165cp |
100万局面では、base が pair accuracy でも rank を追い抜きました。
30万では「ランキング損失は順序を直接最適化する」が勝ち筋でした。しかしデータが十分にあれば、素の回帰も順序を学びます。
ランキング損失はデータ不足の松葉杖でした。凝った損失関数に手を出す前に、データを増やす。
run1m-base 1000ms × 16局 × 3シード 37/48 (77.1%) 全シードで70%超
系譜:19.6% → 32.1% → 77.1%。
設計は「切り替えは大胆に、深さは保守的に」。
この節がこの記事の山場です。
自己対局で77.1%を取り、品質ゲートを堂々と通過して本番へ載せました。系譜も 19.6% → 32.1% → 77.1% と綺麗に伸びていた。一件落着——のはずでした。
この記事を書いている二段が、本番の hard(2秒)で実際に指して、勝ちました。
しかも僅差ではなく、報告つきで。「まだ全然弱い。意味不明な手を指し続ける。」詰みと無関係な位置に歩を打つ、自分から玉を危険地帯へ歩かせる——2秒フルに考えた末に、です。
第1ラウンドの最大の教訓が完全に戻ってきました。「自己対局の勝率は、人間に対する強さを保証しない。」同じ罠に2度目です。
印象ではなく事実で殴るため、81手の棋譜全体を再現ハーネスに通しました。 本番と同じ呼び出し経路で再生し、1手ごとに「NNUE ON で何を選ぶか」と「NNUE OFF(手書き評価)で何を選ぶか」を比較。さらに全手をやねうら王 depth 18 と突き合わせ、どこで何cp失ったかを定量化しました。
最初の容疑は2章の「探索を迂回して即答する」バグの再発でした。違いました。
悪手はNNUE経路で再現し(ON:40手中19手がやねうら王と不一致)、OFFでは不一致7手。つまりNNUE ONのとき、エンジンは2秒フルに探索した上で、その悪手を最善だと信じて選んでいた。
バグではなく、評価関数そのものの欠陥。 診断としては最悪の部類です。
ここが核心です。
学習時、NNUEは教師のcp値を sigmoid(cp/600) に通してから学習します。シグモイドは入力を0〜1に押し込むS字カーブで、入力が大きくなると出力が1に張り付いて動かなくなります(= 飽和)。
cp/600 のシグモイドは、評価値が ±2500cp 付近を超えるとほぼ完全に平坦になります。
学習時はこれで構いません。既に決着した局面を細かく区別する必要はないからです。
しかし実戦の探索では致命傷です。
問題の72手目——自玉が自分から詰みに歩いていく手を選んだ局面(損失31,934cp)——で、NNUEに71手すべての合法手を評価させたら:
71手すべてが、わずか 15cp の帯に潰れていた
何を指しても「だいたい同じ点数」に見える。 同じ局面を手書き評価に見せると、約390cpの広がりを保っていました。手書き評価は決着局面でも手を並べられます。
「決着した局面では、どの手も同じ点数に見える」——これが「意味不明な手」の直接原因です。 手を区別できなければ、エンジンは実質ランダムに近い選び方をします。詰みに歩いていくのも当然です。
48手目に、自陣の一番奥(8a)への無意味な歩打ち(損失1,626cp)を選んでいました。
ここではNNUEが局面をほぼ互角と誤判し、結果として「パスに近い手(何もしないのと同等)」が上位に集まりました。
自陣の最奥に歩を打つのは、将棋では駒を1枚殺すのとほぼ等価な高コストの手です。しかしそのコストがネットにはほとんど見えていません。
なぜか。この種の手が教師データに事実上存在しないからです。やねうら王はそんな手を指さないので、ネットは「自陣最奥への歩打ちがどれだけ悪いか」を一度も教わっていません。 見たことのない愚行のコストは評価できません。
ここで話が第2ラウンドに繋がります。
飽和自体はシグモイドの性質ですが、それを致命傷にまで悪化させた原因は教師データの作り方にありました。
第2ラウンドで --balance を使い、|cp| > 1200 の決着局面を70%捨てて30%だけ残しました。 狙いは「AIが最も悩む中盤と接戦を練習させる」こと——研修医に、教科書的に明白な患者ばかりでなく、微妙で判断の難しい患者をたくさん見せるようなものです。それ自体は完全に健全です。
しかし間引きすぎが飽和を悪化させました。
決着局面を捨てすぎたことで、NNUEは**「決着した局面」をほとんど練習しないまま卒業しました。** だから本番で決着局面に出会うと、手を区別できない——全部同じ点数に見える。
**シグモイドが数学的に平坦になる領域(±2500cp付近)**と、練習不足で「未知」になった領域が、まったく同じ場所で重なった。
研修医の比喩で言えば: 微妙な患者ばかり見せて、明らかな重症患者をほとんど見せずに現場に出したらどうなるか。重篤な患者が運ばれてきたとき「どのくらい悪いのか」の感覚が無く、全員が「中くらい」に見える。
診断が出た以上、崩壊を本番に置いておけません。NNUEの適用範囲を medium のみに狭めました。
冷静に振り返ると、77.1%は1000msでしか測っていませんでした。 hard(2000ms)以上は「深く読めばもっと強いはず」という未検証の外挿でした。しかも深く読むほど飽和領域の局面に到達しやすいので、hardのほうがむしろ崩壊しやすい。
最大の反省は、「自己対局の勝率」を合格基準にしていたこと自体が誤りだったという点です。
77.1%は本物の数字ですが、それが測っていたのは「自己対局で手書き評価に勝てるか」であって、「人間との実戦で愚行を避けられるか」ではありませんでした。代理指標を通過し、肝心の挙動が壊れていた。
新しい合格基準:
実際の対局棋譜を、やねうら王 depth 18 と突き合わせた悪手率
そしてその81手の棋譜を永久の回帰テストにしました。人間が実際に咎めた失敗より価値のあるテストケースはありません。
診断は「決着局面の間引きすぎ = データ分布の問題」を指しました。教師データを一から作り直しました。
変更点(1):balance-rate 0.3 → 0.5
決着局面の採用率を上げます。破棄を70%から50%へ緩める。研修医に重症患者も見せる。
実際に生成した524万局面の分布:
| 帯 | 割合 |
|---|---|
| 互角(|cp|<300) | 23.1% |
| 中盤(300〜1200) | 27.2% |
| 一方的(1200〜3000) | 43.2% |
| 極端(>3000) | 6.5% |
決着局面(|cp|>1200)の割合が、第2ラウンドの36%から49.7%へ回復。 飽和で潰れていた領域が学習データの約半分を占めるようになりました。これが主たる修正です。
変更点(2):採点の深さ 8 → 12
なぜ12なのか、20や30ではないのか。 3点あります。
変更点(3):古い100万局面は混ぜない
「既に作った100万局面も足して550万にすれば?」と思うかもしれません。混ぜません。
古いデータは depth 8 の採点 + balance-rate 0.3 で作られています。つまり今まさに直そうとしている2つの問題(浅い採点、決着局面の間引きすぎ)を抱えた当の本人です。混ぜれば、苦労して直した飽和の修正が薄まります。
病気の原因を、その治療薬に混ぜてはいけない。
古い100万は学習には使わず、「倒すべき基準」——現在本番で動いている重み——として残しました。
(A) holdout(4,000局面)
| 指標 | 旧(run1m) | 新(run5m) |
|---|---|---|
| 決着帯(|cp|>1500)の pair accuracy | 0.8840 | 0.9044 |
| 全体MAE | 531.7cp | 448.8cp |
| 1000〜3000cp帯のMAE | 677cp | 529cp |
一方的な局面での「手の区別」が本当に戻りました。
(B) 飽和ゲート(81手の実戦棋譜 vs やねうら王 depth 18)
悪手(>300cp)の数 8 → 4 (半減)
72手目の手の広がり 20cp → 532cp (26倍)
あの72手目——「71手が同じに見える」の震源——が完全に解消しました。 旧ネットが −35,281cp の悪手(自陣への金打ち)を選んでいた局面で、新ネットは真の最善手(6六→6七成)を選びます。
研修医が、重症患者の重症度をようやく測れるようになった瞬間です。
正直に書くと、74手目にはまだ悪手が残っています。 ただしそこは既に −30,000cp の「どう指しても負け」の局面で、負け方の違いにすぎません。 飽和の修正が片付けたのは「まだ turn し得る決着局面」であって、完全に終わった局面ではない。盤上で意味があるのは前者です。
(C) A/B対局
対 旧NNUE 92.2% (29.5/32)
対 手書き評価 1000ms で 84.4%、2000ms でも 84.4%
今度は hard 相当(2000ms)を実測で通しました。 飽和崩壊を起こした、まさにその未検証だった持ち時間です。
(D) base > rank、再確認
同じ524万局面をランキング損失で学習した run5m-rank は、holdout の決着帯 pair accuracy は良かったのに、実戦の72手目では依然として飽和していました(広がり90cpで、大悪手を選んだ)。
ランキング損失は「順序さえ合っていればよい」ので、一方的な領域の絶対的な広がりを潰す副作用があります。 飽和を治す目的には逆効果でした。
飽和を治すには、大きな差を大きな差として学ぶ base が明確に優れている。
8章でまとめて並べますが、評価関数側の否定的結果をここにも置きます。5連敗しています。
| 試したこと | 結果 |
|---|---|
| データ量 10万 → 80万 → 590万(scratch) | 0勝1分52敗(0.94%) |
| 蒸留データを3.6%追加 | 50.0%(引き分け) |
| 蒸留データを19.6%追加 | 50.6% 39.9, 61.3 |
| 教師の探索を depth 12 → 18 | 教師との一致率 87.4%→96.2%、強さは変わらず |
| 両玉視点(dual-view)の表現 | 4設定すべて却下 |
| BonaPiece型の特徴量表現 | 却下(実live形式で速度契約を62.9%超過) |
| KP特徴(玉との相対位置) | 37.5%。データ希釈が原因 |
| 学習目標に勝敗を混ぜる(WDL) | 44.4% / 42.9%。しかも現コーパスには勝敗ラベルが構造的に存在しない |
KP特徴の話は教訓として価値があります。
狙いは正しかった。本物のNNUEは「自玉との相対位置」で盤を符号化します。同じ「5五の銀」が、玉が居飛車の囲いにいるか振り飛車の囲いにいるかで別の特徴として区別される。7ヶ月の手書き評価で一番難しかったのが「玉の周りの価値付け」だったので、効かないはずがないと思っていました。
結果は37.5%。負け越しです。
原因はデータ希釈でした。教師データは100万局面。玉の位置で6バケットに分けると、単純平均で1バケットあたり17万局面を切ります。さらに悪いことに、実戦では玉が初期位置から動いていない局面が全体の約半分を占めるので、そのバケットがデータを独占し、他が空になります。
表現を細かくすると、モデルの表現力(区別できる状況の数)は増えます。しかしその区別を1つずつ学ぶには、それぞれに十分な例が要ります。10倍細かくすれば、原理的に10倍のデータが要る。表現力とデータ量は独立に上げられません。
KP特徴は方向として正しい。100万局面では早すぎただけです。実装は捨てず、データが増えたら再挑戦する予定です。順序の問題であって、否定ではありません。
| やること | 実測 |
|---|---|
| 学習契約(sigmoid / clamp / K)を間違えない | 間違えると 0勝16敗(9章) |
| warm-start > scratch | scratch は 12.5% で大敗 |
| 量より「どの局面を学ぶか」 | 3.6%/19.6%増量は引き分け、定跡末端1.33%は 66.2% |
| 決着局面を間引きすぎない | 36% → 49.7% で飽和が解けた |
| 採点の深さは「生徒が吸収できる」範囲で | depth 12。20/30は生徒の誤差に埋もれる |
| 古いデータを混ぜない | 病気の原因を治療薬に混ぜない |
| 凝った損失より先にデータ | ランキング損失はデータ不足の松葉杖だった |
| 合格基準を「悪手率」にする | 自己対局77.1%で人間に負けた |
<a id="6"></a>
定跡は費用対効果が良い領域です。序盤の精度は局面数がほぼそのまま効き、評価関数のように飽和しません。
そして人間との対局では、自己対局よりずっと効きます。 理由は後述します。
将棋には何世紀ぶんもの研究があり、「この局面ではこの手が良い」という定型(定跡)の体系があります。
プログラムはこれを 局面 → 推奨手 の表として持てば、序盤で毎回ゼロから考えずに済みます。速いし、AIの指し手が人間に認識できる戦略の形になります。
落とし穴は、悪い手を1つも混ぜてはいけないことです。定跡を抜けたら探索に切り替わるので、1つの悪い定跡手が、そのまま負け筋へ直行させます。
この図の菱形2つが、両方とも私のバグの温床でした。
出発点はこの報告でした。
「本当に強くなった? 原始棒銀で毎回勝てるんだけど。」
原始棒銀はアマチュアの基本戦法です。毎局それで負けるなら失格です。
推測せず、再現スクリプトを書きました。 原始棒銀の手順(▲2六歩→2五歩→3八銀→2七銀→2六銀→1五銀→2四歩…)を本番のAI呼び出し口に投げつけ、1手ごとに評価値と駒得をログします。
結果:medium のAIが49手で詰まされました。
ply 9 ▲ 27->26 evalV3(SENTE)=-485 ← 銀が2六へ進出
ply 16 △ 71->62 evalV3(SENTE)=-892 ← 銀が2四に迫るのに、AIは無関係な銀を上がる
ply 17 ▲ 24->23+ ← 銀が成る
ply 19 ▲ 28->23+ material=+600 ← 飛車が金を取って成る。教科書どおりの棒銀成功
ログから3つの原因が落ちてきました。
(1) 定跡に後手の受けが1つも無かった。 さらに悪いことに「評価値の絶対値が200を超えたら定跡を飛ばす」というゲートが、普通の序盤の評価ノイズで常時発動していて、必要なときに限って受けの定跡を無効化していました。
(2) 飛車先の受けの評価が歩しか見ていなかった。 棒銀の実際の機構(銀の進出 + 飛車の重ね)を完全に無視していました。
(3) 敵陣の成駒が評価から消えていた。 「敵陣の飛車・角に+800」という項目が、駒が成った瞬間に駒種コードが変わってマッチしなくなる古典的なバグ。突破された後、エンジンの評価は「ほぼ互角」と言っていました。
正しい対棒銀の受けをプロの解説で確認しました。「▲2五歩には△3三角」「△1四歩で銀に5段目を渡さない」。
これを「棒銀への圧力」という評価項目(進出度 × 受けの形、両手番で対称)として実装し、両方の定跡に正しい△3三角の変化を入れました。壊れていた定跡データ(8二→3七 のような不正なエントリが「8五歩」と注釈されていた)も修復しました。
修正後、2種類の棒銀手順はどちらも銀を丸損して潰れます。
ここで導入した「敵陣の大駒に ±1000」という項目が、エンジン全体を大幅に弱くしました。
評価回帰の自己対局 2勝7敗 (変更前は 5勝2分3敗)
環境変数のキルスイッチで項目を1つずつ単離して主犯を特定し、±350に下げたら強さが戻りました。
意図した局面で正しい大きな手調整の項目は、局面分布の全体に対しては歪みです。
これ以降、すべての評価変更は、変更前のエンジンを凍結した相手との直接対局を通すことを義務にしました。
そして面白い副作用がありました。 定跡手を安全確認する1手の静的検証器が、「敵陣深くで守られている歩を角で取る」を +1000の妙手と見なすようになり、比較の基準値が跳ね上がって、正しい静かな定跡手を全部却下し始めました。 検証器にもSEE風の「ぶら下がり駒」補正を足しました。
評価・定跡・検証器は結合したシステムです。1つ触ると別が壊れます。
2章で触れた話を、ここで数字とともに置きます。
図の2つ目の菱形——「エンジンの静的評価がその手を許容するか」——の閾値がきつすぎて、超人的エンジンで検証済みの定跡手まで却下していました。
master 9.4%
hard 6.4%
expert 6.4%
閾値を 90cp → 150cp に緩めた結果:
| 指標 | 修正前 | 修正後 |
|---|---|---|
| 定跡離脱手数 | 7.91手 | 8.66手 |
| 1局あたりの駒損イベント | 0.53 | 0.25 (p=0.024) |
このA/Bを最初に組んだとき、約1,500回の定跡参照で不一致が0件でした。 両側が同じ定跡・同じ閾値なので、差が出る局面に到達しない——1章の「構造的にゼロ」です。ゲートが実際に発火する局面を採取し直して、ようやく測れました。
定跡は267局面・34変化でしたが、多くの変化が10〜14手で止まっていました。「戦型が決まる」まさにその手前です。
止まっていること自体は致命的ではありません(抜けたら探索に落ちる)。しかし定跡が長く続くほど序盤が一貫し、即答できる率が上がります。
手で入力した「良さそうだから」の定跡手は、ほぼ必ずどこかに罠があります。 そこでローカルのやねうら王(NNUE、プロ級)を審判にしました。
各候補手を depth 18、MultiPV 2 で読ませ、エンジンの最善手(または最善から50cp以内の次善手)だけを採用する。
具体的には、各変化の終端局面でエンジンに最善手を聞き、追加し、また聞く——エンジンの読み筋を数手追いかけるわけです。これなら追加した手はすべて「超人的エンジンが最善と呼ぶ手」になります。
21の変化(矢倉、角換わり、相掛かり、雁木、四間飛車、三間飛車、中飛車、対振り、横歩取り、対棒銀ほか)をそれぞれ2〜6手ずつ延長:
局面数 267 → 354 (+87)
登録手 298 → 387
検証: 全登録手を depth 18 / MultiPV 2 で再チェックするスクリプトを走らせ(エンジン最善から200cp以上悪い手があれば非ゼロ終了)、追加した87局面で新規の悪手ゼロ。それどころか、雁木のある変化を閾値ギリギリの局面より先へ延長したことで、既存の危うい手が解消しました。
唯一フラグが残ったのは三間飛車の ▲7八飛 ですが、これは現代エンジンが「振り飛車は居飛車より約180cp低い」と判定しているだけです。その手は戦型の定義手(消したら三間飛車が消える)で、かつエンジンが認める最善の三間飛車の試みなので残しました。悪手ではなく、戦型選択の代金です。
定跡を無効化した強制序盤 10勝10敗10分 (回帰なし)
定跡を有効にした条件 15勝0敗15分 (一度も負けなかった)
定跡参照回数 926 → 956
ここに重要な性質が現れています。
自己対局では両側が同時に定跡を抜けるので、定跡の差が出にくい。自己対局は定跡の価値を構造的に過小評価します。
一方、人間は定跡から外れます。 2章の「1msバグ」が人間にしか暴けなかったのと同じ構造です。定跡の改善は、自己対局の数字より実戦で効きます。
現在の出荷定跡は v3、2.69MB、173,172局面です。これは新ペタショック定跡233万局面(やねうら王チームがWCSC35で使用、1局面あたり2億ノード探索のminimax値付き)を大幅に刈り込んだものです。
拡張しようとして、2つの壁にぶつかりました。
壁1:元データの大半は、そもそも到達できない
取り込みは初期局面からのBFS(幅優先探索)です。ペタショックの大部分は中終盤の局面で、序盤から辿り着けません。
| 設定 | 最大手数 | 到達局面 | 元データ比 |
|---|---|---|---|
| default | 24 | 110,164 | 4.9% |
| default | 40 | 402,781 | 17.9% |
| default | 60 | 896,489 | 39.8% |
| wide-12 | 24 | 321,307 | 14.3% |
しかも手数を伸ばすと ply 47 で頭打ちになり、以降は減ります(元データが尽きる)。「233万全部を使う」という選択肢は存在しません。
壁2:ブラウザのJSヒープ
ローダーが Map<hashA, Map<hashB, {moves}>> を作るので、局面あたり約368バイト(ワイヤ上のバイト数の約20倍)。しかもメインスレッドとWorkerの2つの実行環境で二重に持ちます。
| 局面数 | JSヒープ(2環境合計) |
|---|---|
| 173k(現状) | 122 MB |
| 400k | 280 MB |
| 1M | 681 MB |
ダウンロードサイズは問題ではありませんでした(NNUE重み94.7MBに対し、10倍の定跡でも+19%)。バイト数ではなくヒープが壁です。
検証コストも測りました。 depth 18 での再検証は 2.79秒/局面(単体)、14コア並列で 13,556局面/時。この見積もりは、数ヶ月前に実際に行った検証(12プロセスで約2.5時間/49,961局面)と誤差4%で一致しました。400k局面なら週末で終わります。検証は障害ではありません。
結論: 定跡を増やすなら、先にローダーを書き直す(ArrayBufferを保持してハッシュ順のエントリを二分探索し、局面あたりのJSオブジェクトをゼロにする)。これは定跡を増やさなくても現状の122MBが消えるので、それ自体が得です。
そして実測の定跡離脱手数は8.66手です。ply 40の局面を検証するのは、引かれないカードを刷るのと同じ——狙うべきは浅い手数の充実です。
| やること | 実測 |
|---|---|
| 再現スクリプトを先に書く | 棒銀で49手詰みを再現して原因3つを特定 |
| プロの解説で裏を取る | 「▲2五歩には△3三角」 |
| 超人的エンジンを審判にする | depth 18 / MultiPV 2、最善から50cp以内のみ採用 |
| 安全弁がきつすぎないか実測する | 却下率9.4%、緩めて駒損 0.53→0.25 |
| 大きな評価項目は毒 | ±1000 で 2勝7敗、±350 で回復 |
| 評価・定跡・検証器は結合系 | 1つ触ると別が壊れる |
| 定跡拡大の律速はヒープ | 局面あたり368バイト × 2環境 |
| 深くするより浅く充実させる | 実測の離脱手数は8.66手 |
<a id="7"></a>
詰みの見落としは、強い人間が最初に気づく弱点です。Elo換算では小さくても、体感では大きい。
出荷していた詰みソルバーは、9手で打ち切る反復深化のAND/OR探索でした。
反復深化は詰み手数ごとに木を全部読み直すので、深くなるほどコストが急激に増えます。 長い詰みは、約200msの探査予算では手が届きません。
df-pn(depth-first proof-number search) は、「最も証明しやすそうな葉」から先に展開する探索です。
各ノードに 証明数(そこを「詰み」と証明するのに最低いくつのノードを解決すればよいか)と 反証数(「詰まない」と証明するのに必要な数)を持たせ、証明数が最小の枝を深く掘ります。
結果として、狭い一本道の王手の連続を、その終端まで追いかける挙動になります。浅く広い前線にコストを払わないので、手数の上限が要りません。
1,069局面のラベル付きコーパス、200ms(本番の探査予算)、報告された詰みは全て独立に再検証:
既存(反復深化・9手上限) 148 手
df-pn(手数上限なし) 162 手
同じ予算で14手ぶん多く見つかりました。 探索本体のコストは変わりません(詰みが証明されればその手で終わるので、むしろ得)。
詰みでないものを詰みと報告するのは、見落としよりはるかに悪いです。エンジンがその手を指して負けるからです。
証明数はヒューリスティックで、置換表は62ビットの二重ハッシュで引いています。つまり**「詰み」という判定を、それ単体では絶対に信用できません。**
そこで3重の防御を入れました。
(1) 証明木の再構築
extractProof() が、実際の指し手生成を使って木を歩き直し、すべての葉が本物の詰み(受け方の手番・王手されている・合法手がゼロ)であることを再導出できた場合にのみ手を返します。置換表はその歩き方の順序付けにしか使いません。 予算内に再導出できなければ null を返します。
(2) GHI 対策
GHI(Graph History Interaction) は、同じ局面でもそこに至る経路によって千日手の判定が変わる問題です。
対策:経路の繰り返しや手数上限から導かれた値は、それが「反証」であるときはキャッシュしない。 証明木には繰り返し由来の値が入らない(繰り返しは反証しか生まない)ので、GHIはこのソルバーから詰みを奪うことはあっても、詰みを捏造することはできません。
(3) 将棋固有のルールを生成元で排除
設計の指針: 「詰みでない」を返しすぎるのは許容できる。「詰みだ」を間違えるのは許容できない。非対称なコストには、非対称な設計で応じる。
診断で見つかった別の症状です。終盤に入ると探索速度が 27〜41µs/ノード に落ち、到達深さが4〜6手まで下がります(序盤・中盤よりずっと浅い)。
これは評価関数とは無関係で、NNUEを使わない手書き評価でも同じ症状が出ます。
プロファイルすると原因は明確でした。
持ち駒の多い局面での、1ノードあたりのコスト内訳:
指し手生成 約50%
合法性判定 約49%
評価 約 1% ← NNUEは既に十分速い
そして**生成される手の88〜93%が「打つ手」**でした。持ち駒が増えると、打つ手の候補は (空きマス約70) × (駒種) で組み合わせ爆発します。
対処は3章に書いた「読む手を変えずに速くする」3段(合法性判定のスキップ、生成の1パス化、王手判定のビットマスク化)です。合わせて 1ノードあたり約18%減、WASMのperftで +8.5〜9%。
深さが1段跳ぶほどではありません。 正直に書いておきます。ただし強さのリスクをゼロにしたままコストを削ったので、積み上げの土台としては正しい方向です。
証明手数を9手から11手へ伸ばす実験もしましたが、手持ちの終盤局面では追加の詰みが1つも見つかりませんでした(時間を使っただけ)。
効果を確認できないものを、挙動を変えてまで本番に入れるのは、この方針に反します。
中立なら出荷しない。それも1つの判断です。
| やること | 実測 |
|---|---|
| df-pn で手数上限を外す | 200msで 148 → 162 |
| 証明木を再構築してから手を返す | 偽の詰みゼロ |
| GHI は「詰みを奪う方向」にのみ働くよう設計 | 捏造は構造的に不可能 |
| 終盤の遅さは指し手生成と合法性判定が原因 | 評価は既に1% |
| 効果を確認できない変更は入れない | 詰み11手延長は見送り |
<a id="8"></a>
この章が一番節約になるはずです。 私が2ヶ月かけて潰した道を、読むだけで避けられます。
技術ブログはふつう分子しか載せません。ここでは分母を載せます。
| 試したこと | 規模 | 結果 |
|---|---|---|
| 学習データを増やす | 1万 → 80.6万 → 590万(scratch) | 0勝1分52敗(0.94%) |
| 蒸留データを追加 | 子 357,468(全体の3.6%) | 50.0%(引き分け) |
| 蒸留データを追加 | 子 2,065,163(全体の19.6%) | 50.6% 39.9, 61.3 |
| 教師の探索を深く | depth 12 → 18 | 一致率 87.4%→96.2%、強さは変化なし |
| 同じ手法を別の領域で再実行 | — | ちょうど 50.0% |
「もっとデータを」「もっと深い教師を」は、全部止まりました。
特に教師の深さの結果は象徴的です。オフラインの指標(教師との一致率)は8.8ポイント改善したのに、直接対局での強さはまったく動きませんでした。 1章の「静的指標は強さを予測しない」の、最も明快な実例です。
では何が効いたのか。
定跡末端からの蒸留 全体の 1.33% を追加 → 66.2%(採用)
1.33%です。 19.6%を足しても引き分けだったのに、「定跡を抜けた直後の局面」だけを狙って1.33%足したら通りました。
量ではなく、位置。
これが評価関数側で唯一効いた原理です。
| 試したこと | 結果 |
|---|---|
| 両玉視点(dual-view)HalfKP | 4設定すべて却下。速度契約も33.9%超過 |
| BonaPiece型の特徴量 | 却下。実live形式で速度が62.9%超過 |
| KP特徴(玉との相対位置、6バケット) | 37.5%。データ希釈 |
| ネットの幅を広げる | 静的指標は改善、対局では通らず |
KP特徴の教訓を再掲します。
表現を10倍細かくすれば、原理的に10倍のデータが要ります。表現力とデータ量は独立に上げられません。 上げるならセットで。でないと「細かいが中身の無い」特徴を量産するだけです。
| 試したこと | 結果 |
|---|---|
| 王手延長 | A/Bで中立。不採用 |
| 打つ手の遅延枝刈り(LMPの打つ手版) | 16勝24敗8分。1シードも勝てず |
| ProbCut(2種) | ベンチは速いが実対局で負け |
| History gravity | 短い持ち時間では新しい情報を捨てるだけ |
| singular extension | 1秒では判定コストが高すぎる。保留 |
共通する理由: ある筋を深く読めば、別の筋が浅くなる。得と損が相殺して、合計で中立か負けになります。
対して「読む手を変えずに速くする」はビット一致で検証できるので、強さのリスクがゼロです(3章)。
Texelチューニングは、手書き評価の重みを対局結果に合わせて自動最適化する定番手法です。16個の評価重みを座標降下で調整しました。
48局の直接対局 17勝14敗17分 有意な改善なし
面白いのは、なぜ効かなかったかを定量化できたことです。
チューニングに使った局面は、70msの自己対局から採った1,698局面でした。この局面群の「評価 → 結果」のシグナルを測ったら、ノイズ以下だったのです——定数を予測するよりフィット誤差が悪いという水準。
本物のTexelチューニングは数百万局面を使います。 1,698局面では、そもそも調整すべき方向が読み取れません。
副産物として、K推定ループの無限ヒルクライムのバグを発見・修正しました。ハーネス自体は保存してあります(データが増えたら再挑戦できるので)。
手法が悪かったのではなく、その手法が要求するデータ量を用意していませんでした。 5.9節のKP特徴とまったく同じ構図です。
第1ラウンドのNNUE敗北(19.6%)の主犯として、こういう仮説がありました。
NNUEは真のcpを出すが、探索のマージン定数は手書き評価のスケール(真cpの約3.7倍)に合わせて調整されている。だからNNUEには実質3.7倍甘く、枝刈りが緩んでいる。
筋が通っています。 単離してA/Bした結果:
19.6% → 8.9% (−10.7pt、悪化)
棄却。 しかも解釈が面白い:甘いマージンは「保険」として機能していました。 ノイズの多い評価を余分な探索で検算していた。マージンを本来の強さに戻すと、評価のノイズが枝刈り判断に直撃します。
3つの仮説を一度に「修正」していたら、どれが効いたか永久に分かりませんでした。1変数ずつ単離する退屈さが、ここで報われました。
これは「試す前に否定された」珍しい例なので、詳しく書きます。
NNUEの学習では、教師の評価値だけでなく実際の勝敗を混ぜるのが定番です(y = (1-λ)·sigmoid(cp/K) + λ·勝敗)。学習スクリプトにその機能はあり、パラメータ wdl_mix のデフォルトが 0.0 でした。実装済みなのに未使用。
私は「未探索の安いレバーだ」と提案しました。2つの理由で間違っていました。
(1) そもそも学習データに勝敗ラベルが存在しない
コーパスの各行は、教師の候補手を1手指した直後の局面です。最後まで指されたことのない仮想の分岐なので、取ってくる「対局結果」がありません。
実際の対局結果と突き合わせられたのは、**12,508,188行中 7,794行(0.062%)**でした。
(2) 過去に試されていて、結果が芳しくない
別の実行計画に wdl_mix = 0.5 と 0.25 の2アームが残っていました。
44.4% / 42.9%
私は「デフォルトが0だから未検証だろう」と推測して外しました。 設定ファイルを1つ開けば分かることでした。
「フラグのデフォルトが0である」ことは、その軸が未検証である証拠ではありません。
4章で詳述した件を、否定的結果として再掲します。
待ち時間 938.6ms → 633.4ms (−32.5%)
棋力 600局で 44.2% [40.3, 48.2] ≈ −40 Elo
原因は置換表の持ち越しを壊したこと。毎手クリアした条件では赤字が消えました(44.2% → 54.7%)。
「どうせ捨てる反復を省く」という最適化は、このエンジンでは前提から間違いです。
1章で挙げたものを、失敗として数え直します。
| 症状 | 実際の原因 |
|---|---|
| 突然 2勝5敗7分 になった | 強制序盤が2手 → 実質2〜4局の複製 |
| medium で 3勝5敗 | 時間スケールの偏り。hard以上では 6勝2分2敗 |
| ずっと非本番条件で比較していた | 対局スクリプトのデフォルト評価モードが違った |
| 約1,500回の参照で不一致0件 | 両側が同じ定跡 → 差が出る局面に到達しない |
測定器そのものが4回壊れました。
棚卸しの中で、これだけ性質が違う記録があります。
ある候補モデルが事前の判定を全部落としました。 既存の本番モデルとの直接対局は 0勝16敗。全敗です。
それでも明示的な指示で本番資産として差し替えられ、マージされました。その後、実証済みのモデルに戻されました。
棚卸しはこれを「採用」に数えていません。 ゲートに反する強制変更だからです。
後から原因を調べたら、学習ターゲットの設計に3つのバグが重なっていました。
評価関数がほぼ平坦になっていました。 全局面で「互角」と言うモデルだったので、16戦全敗は必然です。
ここで大事なのはバグではありません。判定は正しく「不合格」と言っていたということです。判定を無視した結果、壊れたモデルが一時的に本番に乗りました。
<a id="9"></a>
最後に、全体の数字を出します。
きっかけは、8.5節のWDLブレンドでした。既に試して失敗していることを、有望な新案として提案した。 記録が無ければ、これを何度でも繰り返します。
そこでディスクに残っていた実験の痕跡を全部数えました。 400ディレクトリ、期間は約2ヶ月。さらに、実験記録のディレクトリを作らずに進んだ作業(ブラウザ側の修正など6件)を補遺として足しました。
直接対局で「強くなった」と証明できたのは、45件中3件でした。
正しさの修正としての採用が別に5件あるので、本番に入った変更は合計8件です。ただし**「強くなった」ことを対局で示せたのは3件だけ**です。
| 変更 | 内容 | 実測 |
|---|---|---|
| 静止探索の王手回避 | 探索の末端で王手回避を正しく読む | 768局 69.7% [66.4, 72.9] |
| 既存重みの温め直し | 新しい教師データで既存の重みを追加学習 | 80局 61.9% [50.9, 71.7] |
| 定跡末端からの蒸留 | 定跡を抜けた直後の局面だけを狙って学習 | 80局×2条件 66.2% / 65.0% |
共通点は「量を増やす話ではない」ことです。
1章のA/A対照(同一バイナリが160局で56.9%、p=0.040)を踏まえて、採用判定を計算し直しました。
| 判定 | 局数 | スコア | CI下限 | 50%からの余裕 |
|---|---|---|---|---|
| 温め直しモデル | 80 | 61.9% | 50.9% | +0.9pt |
| 定跡末端蒸留(狙い撃ち) | 80 | 66.2% | 55.4% | +5.4pt |
| 定跡末端蒸留(標準) | 80 | 65.0% | 54.1% | +4.1pt |
蒸留の方は堅い。 独立した2条件で4〜5ポイントの余裕を持って通り、しかも同条件のA/A対照を49.4%で同時に取っています。
問題は1行目です。信頼区間の下限が50%をわずか0.9ポイント上回っただけ。 A/A対照の記録もありません。片側p値は 0.0168——同一バイナリで得た p=0.040 の2.4倍良いだけです。
このモデルが弱いという証拠は何もありません。 ただ、当時「+84 Elo」と書いた数字は、書いたときに思っていたほど確かではありませんでした。
そして皮肉なことに、同じ教訓を7月に768局かけて学んでいました。 1章の候補Aが 59.8% → 51.2% になった件です。768局で学んだことを、その後80局で判断していました。
もう1つ、正直に書いておくべき数字があります。
判定基準を「測定開始前に決めていた」と一次記録で証明できるもの 20 / 99
測り方そのものが、走りながら整備されていきました。 だから初期の数字は、後期の数字より確からしさが低い。それも記録に残しました。
1点足りずに落ちた実験が4件、救済ゼロ。 うち2件はまったく同じ成績(29勝3分24敗 = 61点、必要62点)でした。
この棚卸しには2週間近くかかりました。400ディレクトリを1件ずつ開けて、result.json を読んで、どのゲートを通ってどこで落ちたかを再構成する作業です。
やってよかった理由は、成功率が分かったからではありません。
「もう試した」と分かるようになったからです。
そしてもう1つ。自分が公開した数字を、自分で検証し直せるようになりました。 「+84 Elo」の下限が0.9ポイントしかなかったことは、数え直すまで気づきませんでした。
1. 80局で判断するのをやめた。 同一バイナリ同士が160局で56.9%まで振れる。80局は、判定したい効果と同じ大きさのノイズを持っています。 今は500局以上を標準にしています。1,000局が5時間で回ることは実測済みなので、80局で済ませる理由はありません。
2. A/A対照を必ず先に取る。 「同じもの同士で測ったら何%か」を先に見る。それが52.8%だったなら、候補の53%には意味がない。
3. 静的な指標を信用しなくなった。 一致率を 87.4%→96.2% にして強さゼロ。検証損失が最良のモデルが 6勝21敗。オフラインで良く見えることと、盤の上で強いことは別の話です。
| 実験・診断の総数 | 99 |
| 実験記録ディレクトリ | 400 |
| 直接対局で棋力を測った実験 | 45 |
| 直接対局で証明できた棋力向上 | 3 |
| 正しさの修正としての採用 | 5 |
| 事前に判定基準を決めていたと証明できるもの | 20 / 99 |
| 1点足りずに落ちた実験 | 4(救済 0) |
現時点で残っているレバーを、優先度順に。
1. 置換表のサイズ 4章の発見(手をまたぐ持ち越しに73 Elo相当の価値)から直接出てきます。現状 2^20 = 約105万エントリ。1局の累積ユニーク局面数がこれを超えていれば、価値あるエントリが最初に追い出されています。 検証は占有率を測るだけ、実装は定数1つ。まだ測っていません。
2. 定跡の拡張 ローダーの書き直し(局面あたり368バイト × 2環境 → ArrayBufferの二分探索)が先。それ自体で現状の122MBが消えます。その後、浅い手数を充実させる。
3. correction history 静的評価と実際の探索結果のズレを局面特徴ごとに憶えて補正する、近年の技法。未実装。
4. SMPが本当に効いているかの実測 ヘルパースレッドが黙って落ちる環境があると分かっている以上、そもそも並列が効いているのか測っていません。
5. 実ユーザー対局からの学習 棋譜保存が動き始めたので、実際に到達される局面という新しい分布が手に入ります。R3で唯一効いたのが「局面の選び方」だったので、その原理の正統な延長です。
このプロジェクトは、二段の持ち主が「弱すぎる」と言ったところから始まりました。
即答する定跡バグを摘出し、全難易度を1つの頭脳に統一し、15倍のWASM移植をやり、相手の時間で考えるようにし、定跡を超人的エンジンで監査し、7ヶ月かけて手書きした評価関数を、100万局面から一晩で蒸留したニューラルネットが77.1%で置き換え——そこから飽和で人間に負け、524万局面で治して、決着局面でも手を区別して斬り合えるところまで戻しました。
そして最後に、作者自身が盤の前で「確かに強くなった」と認めました。
代理指標がどれだけ良くても、人間が実際に指して「弱い」と感じたら弱い。逆に、人間が「強い」と認めた瞬間が本物の勝利です。
<a id="appendix"></a>
レバー別に組み替えた本編では時系列が見えないので、ここに実際の順番を置きます。
| 内容 | 結果 |
|---|---|
| V19(futility / SEE-lite / countermove ほか)+ 対棒銀の評価・定跡修正 | ✅ 対V18 68.5% |
| V20 統一エンジン + 速度 + 持ち時間短縮 | ✅ hard で無敗、master は半分の時間で勝ち越し |
| リシンク・フォールバックの削除 | ✅ 即答型の悪手を根絶 |
| 定跡ガントレット(攻め筋の自動テスト) | ✅ 回帰ハーネス化 |
| 銀の侵入に対する評価の盲点を修正 | ✅ 歩を取られる筋を否定 |
| WASM エンジンを本番へ(P1〜P4)+ 5体のエージェント統合 | ✅ 現行に10勝0敗 |
| NNUE 蒸留パイプライン | ✅ コードのみ(データはローカル) |
| WASM上のNNUE推論(差分の遅延適用)+ A/Bハーネス | ❌ 実重みで19.6%。不採用、基盤は保存 |
| ponder(常設の脳) | ✅ 平均深さ 9.00 → 9.35、本番稼働 |
| NNUE スケール較正 + 単離A/B | ❌ 8.9%。仮説を棄却(機構は保存) |
| 教師データ 100万局面 → 再学習 | ✅ 対V3 77.1%、本番へ |
| 定跡を超人エンジンで深く・広く | ✅ 267 → 354局面、新規の悪手ゼロ |
| SIMD128 | ✅ 評価 6.2倍、探索全体 1.4倍 |
| マルチスレッド(Lazy SMP) | △ ノード3.0倍、+58 Elo(n=24、有意でない) |
| KP特徴 | ❌ 37.5%。データ希釈 |
| 本番hardで人間(二段)に敗北 | ❌ シグモイド飽和が判明。medium のみへ縮退 |
| 教師データ 524万局面(balance 0.5 / depth 12) | ✅ 対旧NNUE 92.2%、飽和が解消 |
| pure-NNUE 化(hard 以上を再開放) | ✅ 作者が盤の前で「確かに強くなった」と認める |
| 終盤の探索高速化(3段:合法性 / 生成 / 王手判定) | ✅ ビット一致のまま ノード +6.7% |
| 静止探索の王手回避 | ✅ 768局 69.7% |
| 時間制御(soft/hard limit) | ❌ 600局 44.2%(−40 Elo)。不採用 |
絶対レーティングは測っていません(それは人間相手に決まるものなので)。しかし世代間の対戦はすべて測りました。
| 遷移 | 実測 | 概算 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 |
| + ponder | 平均深さ +0.35 | +20〜40 |
| + 定跡監査 | 自己対局では互角、人間に刺さる穴を11個潰した | 対人で効く |
| V3 → NNUE | 77.1%(1000ms、48局) | +210 |
累計で概ね +800〜1000 Elo。 初心者が有段者になる程度の幅です。ただし注意書きつきで——自己対局のEloは人間に対する強さを過大評価しますし、絶対的な基準点は不明のままです。本当の採点は盤の前で行われます。
リポジトリは非公開なので、要となる実装を抜粋します(説明のため簡略化)。
実戦で怪しい手が出たら、本番の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;
打つ手の最適化(3章)を足すとこうなります。
const mover = k.teban;
k.move(te);
// 打つ手(te.from === 0)は味方を足すだけなので自玉を晒せない。
// 元々王手でなければ常に合法 → 判定をスキップ。
if ((te.from !== 0 || parentInCheck) && isKingInCheck(k, mover)) {
k.back(te); continue;
}
// 王手がかかっていない静止探索が読むのは、取る手・成る手だけ。
// それらを前に寄せて、その前半だけを挿入ソートする。
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 12');
// "info ... score cp -2161 ..." を収集し、"bestmove" で確定
const cp = lastInfo.match(/ score cp (-?\d+)/)?.[1];
// プール並列: 8エンジンが共有の未処理配列を奪い合う
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 のたびにアキュムレータを更新
// (探索は大量の手を指してすぐ枝刈りするので、全部にコストを払う → perft +1348%)
// ✓ 採用した実装: makeMove は差分をスタックに積むだけ。
// 実際に評価が起きるときにだけ畳み込む。
function makeMove(te: Move): void {
applyBoard(te); // 盤は即座に更新(ナノ秒)
nnuePending.push(encodeDiff(te)); // アキュムレータは触らない
}
function nnueEvaluate(): i32 {
while (nnuePending.length > 0) foldDiffIntoAccumulators(nnuePending.shift());
const acc = sideToMove === SENTE ? accSente : accGote; // 両視点を保持
return forwardFromAccumulator(acc);
}
function unmakeMove(te: Move): void {
if (wasApplied(te)) unfoldDiff(te); // 畳み込み済みの差分だけ戻す
else nnuePending.pop(); // まだなら保留を取り消すだけ
revertBoard(te);
}
// 結果: 1回の評価が 6.2µs → 1.15µs、NNUE有効でも深さ9〜15を維持
// ランダム自己対局50局 × 最大80手 = 4,184局面、毎手 JS vs WASM を照合
for (const pos of randomGamePositions) {
assert(jsLegalMoveCount(pos) === wasmLegalMoveCount(pos)); // 合法手数
assert(jsHash(pos) === wasmHash(pos)); // Zobrist のビット一致
assert(jsEval(pos) === wasmEval(pos)); // 整数で完全一致する評価
}
// 探索: 固定深さで、最善手・評価値・そしてノード数まで一致すること(48/48局面)
// 「だいたい合っている」を許すとバグがすり抜ける。ビット一致を要求すれば全部捕まる。
これらの抜粋で、記事中の実験はすべて再現できます。パイプライン全体(教師生成、学習、量子化、WASM推論)は、無料のオープンソースだけで動きます。