はじめに
将棋AI「技巧(ぎこう)」を自分の手で訓練して強くしてみる開発日記、第2回です。
前回はソースからビルドして、評価関数も定跡もない「知識ゼロの脳みそ」を起動させて、score cp 0(評価値ずっと0)を眺めて終わりました。
ちなみに技巧の本家リポジトリはこちらです(オープンソース・GPL)。
今回はいよいよ学習の入口、学習用の棋譜データを集めて、--learn-progress(進行度の学習)までやります。……が、結論から言うとデータ集めで盛大にやらかしました。そのやらかしと、それをどう見つけて直したかが今回の本体です。
相変わらず Claude(Claude Code)と一緒に 進めています。というか今回の「やらかし発見」はほぼClaudeのお手柄でした。Pythonしか触ってこなかった人間なので、C++の事情も将棋AIの事情も手探りです。間違いはそのまま残していきます。
今回のゴール
- 学習に使う棋譜データベース(
kifu_db.txt)を用意する - 集めたデータがどんなものか軽くEDAする
-
--learn-progressで「進行度」を学習する
棋譜はどこから集める?
技巧の学習は、棋譜データベース(対局の記録の集まり) を教師にします。プロの棋戦DBを使うのが本来の想定ですが、大量に手に入れるのは大変。そこで定番の floodgate(コンピュータ将棋同士が24時間戦い続けている対局場)の棋譜を使うことにしました。
floodgateは1年分の対局を .7z アーカイブで配布してくれているので、それを落として、技巧が読める kifu_db.txt 形式に変換する――という流れです。この「CSA→kifu_db変換」を自作のskill(/fetch-kifu)にやらせました。
技巧の棋譜DBは「1局 = 2行」というシンプルな形式です。
<番号> <日付> <先手名> <後手名> <結果> <手数> <棋戦> <戦型>
<指し手をスペース区切りで1行>
<結果> は 先手勝ち=1 / 後手勝ち=2 / 引き分け=0。ここが今回の地雷でした。
やらかし① 後手勝ちが、1局も、ない
集めたデータ(約3万局)で、まず軽く中身を見てみようとClaudeに頼んだら、結果列の分布がこうなりました。
result=1 先手勝ち : 16,790
result=0 引き分け : 13,210
result=2 後手勝ち : 0 ← ???
後手勝ちがゼロ。 3万局やって後手が一度も勝たないとか、ありえません。将棋の先手勝率はせいぜい52〜54%なので、後手だって普通に半分近く勝つはずです。
原因を追ってもらったら、変換スクリプトのバグでした。CSAファイルの終局行('summary:)から勝者を取り出す正規表現が、
# 旧(バグ):"〇〇 win:△△ lose" の語順しか拾えない
re.search(r"'summary:\w+:(\S+) win:(\S+) lose", line)
となっていて、「win が先」の語順しかマッチしない。ところが実際のCSAは両方の語順がありました。
'summary:toryo:debugengine win:testengine lose ← 先手勝ち。マッチする
'summary:toryo:debugengine lose:testengine win ← 後手勝ち。マッチしない!
後手勝ちは必ず lose:...win の語順になるので、ことごとく取りこぼして「引き分け(0)」に倒れていた、と。だから先手勝ち16,790 + 謎の引き分け13,210(=拾えなかった後手勝ちの山)という分布になっていたわけです。
語順に依存せず「win タグが付いた側を勝者」と判定するように直しました。
# 新:win/lose の順番に関係なく勝者を判定
m = re.match(r"'summary:\w+:(\S+) (\w+):(\S+) (\w+)", line)
if m:
name1, tag1, name2, tag2 = m.group(1), m.group(2), m.group(3), m.group(4)
winner = name1 if tag1 == "win" else (name2 if tag2 == "win" else None)
result = 0 if winner is None else (1 if winner == sente else 2)
千日手(sennichite)は両者 draw なので、ちゃんと引き分け=0に落ちます。
教訓:データは作ったら必ず分布を見る。 「なんとなく動いた」を信じてそのまま学習に突っ込んでいたら、「先手は絶対に負けない」という狂った評価関数が出来上がっていたはずです。こわい。
やらかし② --learn がfloodgateの棋譜を全部捨てる
ついでにもう1個見つかりました。評価関数の学習(--learn)のコードを読むと、棋譜を読み込む部分でこんなフィルタがかかっていました。
game_db.set_title_matches_only(true); // 7大棋戦+順位戦だけ使う
竜王戦 名人戦 … といったプロのタイトル戦の棋譜しか採用しない設定です。ところが僕が集めたfloodgateの棋譜は棋戦名が全部 Floodgate。つまりこのままだと --learn が使える棋譜は実質0局。
本来の技巧が「プロ棋譜前提」で作られていた名残ですね。今回はトレース目的でfloodgateを使うので、ここは割り切ってフィルタを外しました(1行 true→false)。
// floodgateの棋譜を学習に使うため、棋戦の限定を解除
game_db.set_title_matches_only(false);
棋譜を集め直す
バグを直したので、改めて floodgate の2025年アーカイブ(約335MB)を落として、修正版スクリプトで全部変換し直しました。展開すると 128,843局 ものCSAファイルが出てきて、なかなか壮観です。
added=127778 skipped=1065 result0(draw)=5512 result1(sente)=65904 result2(gote)=56362
今度はちゃんと後手も勝っています。kifu_db.txt は約104MB、127,778局のデータベースになりました。
集めたデータを眺める(EDA)
Pythonで軽くEDAしてみます。Kaggleごっこで覚えたやつがこんなところで役に立つとは。
結果の分布:
| result | 意味 | 局数 | 割合 |
|---|---|---|---|
| 1 | 先手勝ち | 65,904 | 51.6% |
| 2 | 後手勝ち | 56,362 | 44.1% |
| 0 | 引き分け/千日手 | 5,512 | 4.3% |
決着局だけで見た先手勝率は 53.9%。実際の将棋の先手勝率(52〜54%)とぴったり重なって、ようやく「まともなデータになった」と安心しました。
手数の分布:
min=1 max=512 mean=111.5 median=108
0-49 : 11.7% ####
50-99 : 31.8% ############
100-149 : 35.3% ############## ← 最頻
150-199 : 16.0% ######
200-299 : 4.6% #
300+ : 0.6%
中央値108手のきれいな山。引き分け局だけ平均95.8手と短めで、「千日手は早めに成立しがち」というのが数字に出ていて面白い。
ほかにも、登場プレイヤーは997人、gikou2_1c や suisho5-1M みたいな有名エンジンがずらり。floodgateは強いエンジンも弱いエンジンも混ざっているので、棋譜の質はピンキリ。本来プロ棋譜を使う理由はここ(質の担保)なんだな、と腑に落ちました。質を上げたいなら「強いエンジン同士の対局だけ」に絞る手もありそう。これは将来の課題に。
進行度を学習する(--learn-progress)
データができたので、いよいよ学習1段目。
./bin/release --learn-progress
そもそも「進行度」って?
進行度は、いまの局面が「序盤(0.0)〜終盤(1.0)」のどのへんかを表す1つの数字です。技巧の評価値は「序盤用」「終盤用」など複数の重みを同時に持っていて、この進行度を使って序盤の評価と終盤の評価をブレンドします。同じ「金」でも、序盤なら囲いの価値、終盤なら寄せの価値、と局面段階で重みを変えるための土台ですね。
どうやって学習する?
仕組みを読んでみると、思っていたよりシンプルでした。盤面から進行度を出すまでの流れはこんな感じ。
そして教師ラベル(正解) は「その局面が対局の何手目か」を正規化しただけ、というのが面白いところ。
教師ラベル = 今の手数 ÷ その対局の総手数
開始局面 投了
│1手目 最終手│
▼ ▼
0.0 ──── 0.25 ──── 0.5 ──── 0.75 ──── 1.0
└── 序盤 ──┴── 中盤 ──┴── 中盤 ──┴── 終盤 ──┘
- 入力:両方の玉の位置 × 盤上の各駒の位置(いわゆるKP=玉と駒の関係)
- 出力:重みを全部足してシグモイドで0〜1に圧縮した値
- 損失:(推定した進行度 − 正解の手数割合)² を小さくする
要するに「盤面の駒配置だけ見て、この対局はどのくらい進んだ局面か当てなさい」という回帰問題。なるほど、これなら勝敗ラベルがなくても学習できる(さっきのバグの影響を受けにくい)。
結果①:ちゃんと収束した
50,000局・約540万局面を使って2048回まわした結果がこちら。
| iteration | 交差検定の誤差(RMSE) | 初期局面の進行度 |
|---|---|---|
| 1 | 0.263 | 0.443 |
| 256 | 0.110 | 0.040 |
| 2048(最終) | 0.110 | 0.037 |
文字でも可視化してみます。
交差検定の誤差 RMSE(小さいほど良い)
iter 1 |████████████ 0.263
iter 256 |█████ 0.110
iter 2048 |█████ 0.110 ← 256回で頭打ち=過学習なし
初期局面の進行度(学習が進むほど "序盤=0" に近づくのが正解)
iter 1 |████████████████████ 0.443
iter 256 |██ 0.040
iter 2048 |█▌ 0.037 ← ほぼ0に張り付く=モデルが正しい
平手の開始局面はどう見ても「序盤=0」なので、推定値がほぼ0に張り付いたのはモデルがちゃんと学習できている証拠です。
結果②:学習した「駒の感覚」を覗いてみる
学習後、「どの駒がどのマスにいると終盤っぽいか」の重みが出力されます。これをヒートマップにしてみました(自玉を8八に固定したときの、各マスにその駒がいる場合の"終盤寄り度")。
濃いほど終盤寄り(+)、点(·)は序盤寄り(−)です。
【金】 自玉=8八に固定 / 上=敵陣 下=自陣
9 8 7 6 5 4 3 2 1 (筋)
一 ▓ ▓ ■ ■ ■ ▓ ▓ ░ ░ ← 敵陣に進出=終盤寄り(濃い)
二 ▓ ▒ ▓ ▓ ▓ ▓ ▓ ▒ ▒
三 ▓ ■ ▒ ▓ ▓ ▒ ▓ ■ ▒
四 ▓ ▒ ▒ ▒ ▒ ▒ ▓ ▓ ▒
五 ▒ ▒ ▒ ▒ ▓ ▓ ▓ ▒ ▒
六 ▒ ░ · ░ ▒ ▒ ▓ ▓ ▓
七 ░ · ░ ░ ░ ▒ ▓ ▓ ▓
八 ░ ░ · · · · ▒ ■ ▓ ← 自玉の周り=序盤寄り(点)=囲いの金
九 · · ░ · · ░ ▒ ▓ ░
凡例 ■≥.13 ▓≥.07 ▒≥.02 ░≥-.03 ·<-.03
金は 自玉の周り(左下)に居ると序盤寄り=囲いの金、前に出る・敵陣に進出すると終盤寄り。完全に人間の感覚どおりです。
飛車はもっとはっきり出ました。
【飛】 自玉=8八に固定 / 上=敵陣 下=自陣
9 8 7 6 5 4 3 2 1 (筋)
一 ▓ ▓ ▒ ▓ ▓ ▓ ▓ ▓ ▒ ← 敵陣まで成り込む=終盤寄り
二 ▒ ▓ ▓ ▒ ▓ ▓ ▓ ▓ ▓
三 ▒ ▒ ▒ ▒ ░ ▒ ▓ ▒ ▒
四 ░ ░ ░ ░ ░ ░ ░ · ░
五 · ░ ░ ░ ░ ░ ░ ░ ░
六 ░ ░ · ░ ░ ░ ░ ░ ░
七 ░ ▒ ▒ · · · · ░ ░
八 · · ░ · ░ · · · · ← 自陣の定位置=序盤寄り
九 · · · ░ · · · · ·
凡例 ■≥.13 ▓≥.07 ▒≥.02 ░≥-.03 ·<-.03
4段目から下(自陣側)はほぼ序盤寄り、敵陣の1〜2段目だけ終盤寄り。「飛車が敵陣に成り込む=終盤が進んでいる」 が一目瞭然です。
ただの二乗誤差の最小化なのに、出てきた重みが将棋の直感そのままで、ちょっと感動しました。モデルがちゃんと将棋を理解しているように見える。
今回のまとめ
Gikou
├─ プログラム本体 ✓
├─ USI通信 ✓
├─ 棋譜DB(kifu_db.txt) ✓ ← 今回(やらかし修正済み)
├─ 進行度(progress.bin) ✓ ← 今回
├─ 評価関数(params.bin) ✗ ← 次回の本丸
└─ 定跡(book.bin) ✗
- floodgateから12.8万局を集めて、変換バグ(後手勝ち消失)と棋戦フィルタを直して、ようやくまともな棋譜DBが完成
- 「データは必ず分布を見る」を身をもって学習
- 進行度学習は成功。初期局面0.037、誤差0.110、駒の重みも将棋の直感と一致
次にやること
いよいよ評価関数の本体(--learn) の学習です。これは各局面で実際に探索を回すので、僕のマシンでも十数時間コースの長丁場。実はこの記事を書いている裏で既に回し始めていて、score cp 0 から卒業できるかどうか、次回お見せできればと思います。乞うご期待。
(余談:「GPU(RTX4090)使えば速くなる?」とClaudeに聞いたら「技巧の評価関数は手作りの線形モデルでCPU並列だけ、GPUの出番はゼロ」とバッサリ。GPUが効くのはNNUEやdlshogi系の話で、技巧は別物なんですね。勉強になった。)