0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

機械学習に詳しくないエンジニアが、調べながら RTX 3060 1 枚で将棋 AI を作った(dlshogi 方式・手法とコードを全部公開)

0
Posted at

こんにちは、高柴です。ふだんは業務システムを作っているソフトウェアエンジニアです。

機械学習は、正直に言うと専門ではありません。大学で研究していたわけでもないし、仕事で使っているわけでもない。それでも「将棋 AI って、個人でどこまで作れるんだろう」と気になって、調べながら作ってみました。

使ったのは家にある RTX 3060 が 1 枚だけです。結果から書くと、こうなりました。

  • 強い AI と同じ手を選ぶ割合(指し手の一致率)が 52.3%
  • 改良版が最初の版に 31 勝 9 敗
  • 探索を速くしたら、1 秒に読める局面が約 1,450 → 約 5,500〜7,000 に増えて、同じネットどうしで 18 勝 2 敗

この記事では、何を調べてどう作ったのかを、コードも含めて全部書きます。コードは GitHub に置いてあります(将棋は backend/rl/shogi/)。

開発は AI(Claude Code)と相談しながら進めました。どの方式にするか、ネットの大きさをどうするか、過学習をどう直すか、といった設計の判断を AI とやり取りしながら決めて、実装も一緒に進めています。そのうえで、出てきた結果が本当に正しいのかは、自分で数字を見て確かめました。専門家じゃなくても、こうやって一つずつ判断していけば形になる、というのが今回いちばんの発見でした。

最初に考えたのは AlphaZero だった

将棋 AI と聞いて、まず頭に浮かんだのは DeepMind の AlphaZero でした。棋譜を一切使わず、AI 同士の自己対戦だけで強くなるやつです。かっこいいし、これをやりたいと思いました。

ただ、調べていくうちに無理だとわかりました。AlphaZero が将棋を覚えたときは、自己対戦の対局を作るだけで数千台の TPU を使っています。

考えてみれば当たり前で、最初の AI は何も知らないので、ほぼでたらめに指します。そこからまともに指せるようになるまでに、何百万局という対局が要る。しかも 1 手ごとに探索しながら指すので、1 局がとにかく重い。さらに、うまくいっているかどうかが、かなり学習が進むまでわからない。

家のパソコン 1 台でやったら、何年かかるかわかりません。ゼロから自己対戦で育てるのは、個人には敷居が高すぎました。

dlshogi のやり方なら、いけそうだった

そこからさらに調べて見つけたのが、世界コンピュータ将棋選手権で優勝した dlshogi です。こちらは考え方が違っていて、

  1. 強い AI 同士の棋譜を集める
  2. 「この局面で強い AI はどう指したか」「最後にどっちが勝ったか」を、ニューラルネットに覚えさせる(教師あり学習)
  3. そのネットを使って、モンテカルロ木探索で先を読む

という流れです。

正解(強い AI の指し手)が最初からあるので、やっていることは画像分類とほとんど同じです。うまく学習できているかも、「強い AI と同じ手を選べた割合」を見ればすぐわかる。学習も GPU 1 枚で数時間で終わる。これなら自分にもできそうだと思えました。

用意したもの

  • GPU:RTX 3060(12GB)
  • Python、PyTorch、numba
  • 将棋のルール:cshogi(GPL-3.0)
  • 棋譜:コンピュータ将棋の対局サイト floodgate の公開棋譜

cshogi には本当に助けられました。合法手の生成から、局面の保存形式、dlshogi と同じ入力の特徴量を作る関数まで全部入っています。将棋のルールを一から書いていたら、たぶんそこで力尽きていました。

棋譜を学習用のデータにする

floodgate の棋譜のうち、レーティング 3000 以上の AI 同士の対局だけを使いました。弱い AI の手を覚えさせても仕方がないので。

cshogi には hcpe という、1 局面を 38 バイトに詰めて保存する形式があります。1 件に「局面・指された手・その局面の評価値・最後の勝敗」が入っています。

# 2025 年分の棋譜 → 約 820 万局面(20 分くらい)
python -m rl.shogi.prepare data/shogi/floodgate2025 --min-rating 3000

820 万局面を丸ごとメモリに載せると重いので、学習のときは np.memmap で必要な 1 件ずつ読むようにしています。

ネットは ResNet に頭を 2 つ付けたもの

ネットは画像認識でよく使う ResNet です。将棋盤を 9×9 の画像のように扱います。出口が 2 つに分かれていて、

  • 方策:次の一手の確率。「どのマスへ、どの方向から動くか」を 27 方向 × 81 マス = 2,187 通りで表す
  • 価値:この局面から、手番側が勝つ確率

を同時に出します。入力は cshogi の make_input_features がそのまま作ってくれます(盤上の駒と利き、持ち駒の数、王手かどうか)。

class ResBlock(nn.Module):
    def __init__(self, ch: int):
        super().__init__()
        self.c1 = nn.Conv2d(ch, ch, 3, padding=1, bias=False)
        self.b1 = nn.BatchNorm2d(ch)
        self.c2 = nn.Conv2d(ch, ch, 3, padding=1, bias=False)
        self.b2 = nn.BatchNorm2d(ch)

    def forward(self, x):
        h = F.relu(self.b1(self.c1(x)))
        h = self.b2(self.c2(h))
        return F.relu(x + h)


class PolicyValueNet(nn.Module):
    def __init__(self, blocks: int = 10, channels: int = 128):
        super().__init__()
        self.in1 = nn.Conv2d(FEATURES1_NUM, channels, 3, padding=1, bias=False)  # 盤上の駒と利き
        self.in2 = nn.Conv2d(FEATURES2_NUM, channels, 1, bias=False)             # 持ち駒・王手
        self.in_bn = nn.BatchNorm2d(channels)
        self.blocks = nn.Sequential(*[ResBlock(channels) for _ in range(blocks)])
        # 方策: 各マスについて 27 方向ぶんの点数
        self.policy = nn.Conv2d(channels, 27, 1, bias=False)
        self.policy_bias = nn.Parameter(torch.zeros(27 * 81))
        # 価値
        self.v_conv = nn.Conv2d(channels, 27, 1, bias=False)
        self.v_bn = nn.BatchNorm2d(27)
        self.v_fc1 = nn.Linear(27 * 81, 256)
        self.v_fc2 = nn.Linear(256, 1)

    def forward(self, f1, f2):
        x = F.relu(self.in_bn(self.in1(f1) + self.in2(f2)))
        x = self.blocks(x)
        policy = self.policy(x).flatten(1) + self.policy_bias
        v = F.relu(self.v_bn(self.v_conv(x))).flatten(1)
        value = self.v_fc2(F.relu(self.v_fc1(v))).squeeze(-1)
        return policy, value

学習

損失は 2 つの頭の和にしました。方策は「強い AI が指した手」を正解にした分類、価値は「勝ったかどうか」の 2 値分類です。

with torch.autocast("cuda"):          # 混合精度
    p, v = model(f1, f2)
    policy_loss = F.cross_entropy(p.float(), label)
    value_loss = F.binary_cross_entropy_with_logits(v.float(), value)
    loss = policy_loss + value_loss
scaler.scale(loss).backward()

最適化は SGD(momentum 0.9、Nesterov、weight decay 1e-4)で、学習率は OneCycle(最初の 5% で 0.1 まで上げて、あとは下げていく)。バッチは 1,024 です。

学習の進み具合は、学習に使っていない局面で指し手の一致率を測って見ていました。dlshogi 系だと 40〜50% くらいが目安だそうです。

価値の頭が「対局を丸暗記」していた

最初の版(v1、10 ブロック × 128 チャンネル)は、指し手の一致率が 50.3% 出ました。お、いいじゃんと思ったんですが、価値の損失を見ると、学習データでは 0.30 なのにテストでは 0.59。完全に過学習です。

原因を調べてみると、正解の付け方の問題でした。1 局の中の局面は、序盤から終盤まで全部「この対局は先手の勝ち」という同じ正解を持っています。対局の数に対して局面の数がずっと多いので、ネットは「この局面は良いか悪いか」ではなく、「この並びはあの対局だから勝ち」と、対局そのものを覚えてしまっていたんです。

AI の評価値を正解に混ぜたら直った

floodgate の棋譜には、指した AI がその局面をどう見ていたか(評価値)が記録されています。これを勝率に直して、勝ち負けの正解に半分混ぜることにしました。dlshogi も同じことをしています。

EVAL_SCALE = 600.0  # 評価値 600 点で勝率 約 73%

score = int(r["eval"])      # hcpe の評価値は「先手から見た値」
if board.turn != BLACK:
    score = -score          # 手番側から見た値に直す
if eval_mix and score:
    winrate = 1 / (1 + np.exp(-score / EVAL_SCALE))
    value = (1 - eval_mix) * value + eval_mix * winrate

ひとつ地味な落とし穴があって、hcpe の評価値は先手から見た値で入っています。手番側から見た値に直さないと、後手番の局面だけ正解が逆になります。

これで、同じ対局の中でも「ここはまだ互角」「ここで一気に良くなった」という違いを学べるようになります。ついでにネットも 15 ブロック × 192 チャンネルに大きくして、5 周回しました。RTX 3060 で 4.3 時間くらいです。

python -m rl.shogi.train --run-name v2 --blocks 15 --channels 192 --eval-mix 0.5 --epochs 5 --workers 8
ネット 指し手の一致率 価値の損失(テスト / 学習) 対局
v1 10 ブロック × 128 50.3% 0.59 / 0.30 —
v2 15 ブロック × 192、評価値を半分混ぜる 52.3% 0.475 / 0.44 v1 に 31 勝 9 敗

学習とテストの差がかなり縮まりました。対局は、互角の局面から先手と後手を入れ替えて、1 手 1 秒で 40 局です。

探索(PUCT)

ネットだけでも一応指せますが、先を読ませるとずっと強くなります。探索は AlphaZero や dlshogi と同じ PUCT にしました。ざっくり言うと、

  1. 根から「これまでの平均勝率」+「方策の確率 × まだあまり読んでいない度合い」が一番大きい手をたどる
  2. たどり着いた先の、まだ評価していない局面をネットに見せて、方策と価値をもらう
  3. その価値を、たどってきた道に沿って根まで足し込んでいく
  4. これを何千回、何万回とくり返して、一番たくさん読んだ手を指す

という仕組みです。手を選ぶところは何万回も呼ばれるので、numba で機械語にしています。

C_PUCT = 1.5
FPU_REDUCTION = 0.2  # まだ読んでいない手の勝率は「親の勝率 − 0.2」とみなす

@njit(cache=True)
def select(n, w, prior, value):
    total = n.sum()
    parent_q = w.sum() / total if total > 0 else value
    sq = math.sqrt(total + 1.0)
    best, best_i = -1e9, 0
    for i in range(len(n)):
        q = w[i] / n[i] if n[i] > 0 else parent_q - FPU_REDUCTION
        s = q + C_PUCT * prior[i] * sq / (1.0 + n[i])
        if s > best:
            best, best_i = s, i
    return best_i

価値を根まで戻すところはこれだけです。将棋は 1 手ごとに手番が入れ替わるので、1 手さかのぼるたびに勝率を 1 - v でひっくり返します。

def backup(path, leaf_value):
    v = leaf_value                  # 末端の局面の手番側から見た勝率
    for node, i in reversed(path):
        v = 1.0 - v                 # node の手番側から見た勝率に直す
        node.w[i] += v

探索を 4 倍速くした話

探索は Python で書いているので、最初はかなり遅かったです。1 秒に 1,450 局面くらい。

調べてみると、遅いのはネットの計算そのものではありませんでした。GPU に命令を出す、次に読む手を選ぶ、結果を書き戻す、といった 1 回ごとの細かい手間のほうが重かった。なので、そこを一つずつ削っていきました。

まとめて GPU に送る(仮の負け)

1 局面ずつ GPU に送ると、GPU はほとんど待っているだけになります。そこで、たどった道にいったん「仮の負け(virtual loss)」を入れておきます。そうすると次のたどり方は別の道を選ぶので、何十局面か集めてから 1 回で GPU に送れます。

node.n[i] += 1  # たどったときに訪問回数だけ先に足す(= 勝ち 0 の「仮の負け」)
# 評価が終わったら backup で勝ち数 w を足して、本物の結果にする

CUDA グラフと fp16

PyTorch で普通にネットを呼ぶと、層ごとに GPU へ命令が飛びます。バッチが小さいと、計算より命令を送る手間のほうが長くなってしまう。

CUDA グラフを使うと、一連の命令を「録画」しておいて、1 回の命令でまとめて流せます。録画は同じ大きさの入力にしか使えないので、バッチの大きさごとに録画しておきました。

for size in (1, 8, 16, 32, 64, 128, 256):
    g = torch.cuda.CUDAGraph()
    with torch.cuda.graph(g):
        logits, value = net_half(in1[:size], in2[:size])   # fp16 にしたネット
    graphs[size] = (g, logits, value)

# 使うときは、入力をコピーして g.replay() するだけ

GPU が計算している間に、次のバッチを集める

入出力の置き場を 2 組用意して、GPU が 1 組目を計算している間に、CPU が 2 組目を集めるようにしました。入力は pin_memory に置いて、GPU を待たずに転送できるようにしています。

詰み探索は別プロセスへ

将棋では、ネットの感覚より「詰みがあるかどうか」を正確に読むことが大事な場面があります。cshogi には詰みを探す df-pn が入っているんですが、これが動いている間は Python の GIL を握りっぱなしになるので、同じプロセスで動かすと探索ごと止まってしまいます。なので、長い詰みは別プロセスで探して、探索の末端では 3 手詰めだけ調べるようにしました。

前の探索の木を捨てない

相手が指した手の先の部分木を、そのまま次の根として使います。相手が考えている間も読み続けるので、その分はそのまま次の手に効きます。

結果

1 秒に読む局面 同じネットで対局(1 手 1 秒・20 局)
速くする前 約 1,450 —
速くした後 約 5,500〜7,000 18 勝 2 敗(Elo 約 +382)

ネットは同じなのに、読む量が 4 倍になっただけでここまで勝つようになりました。速さがそのまま強さになる、というのを実感した部分です。

ちなみに木は 1 局面あたり 1.6KB くらいメモリを使うので、50 万局面(800MB くらい)で読むのを止めるようにしています。

デスクトップアプリにした

最後に、PySide6 でデスクトップアプリにしました。サーバーを通さず、アプリの中で GPU を使って直接考えます。相手の考慮中も先読みするし、AI から見た勝率や読み筋、勝率のグラフも出ます。詰みを読み切ったら「詰みを読み切りました」と出して、その手順で指してきます。

作っていて引っかかったのは、待ったの扱いです。AI が考えている途中で待ったをすると、古い局面で読んだ手をそのまま指してしまうことがありました。なので、待ったや新しい対局のたびに番号を増やして、それより前に考え始めた結果は捨てるようにしています。

やってみて思ったこと

機械学習の専門家じゃなくても、調べてやり方さえ選べば、将棋 AI は GPU 1 枚で作れました。

一番大きかったのは、AlphaZero 方式をあきらめて dlshogi 方式にした判断だと思います。このあたりは AI ともかなり相談しました。自己対戦でゼロから育てるのは、個人のパソコンでは計算が全然足りない。強い AI の棋譜があるなら、それを先生にしたほうがずっと早いです。

あとは、数字をちゃんと見ること。価値の頭の丸暗記も、学習とテストの損失を並べて見ていなければ気づかなかったと思います。

同じリポジトリで、テトリス・オセロ・ポーカー・3D レースの AI も作っています。次はポーカーの AI について書くつもりです。相手の手札が見えないゲームなので、将棋とはまったく違うやり方になって、これはこれで面白かったです。

最後まで読んでいただき、ありがとうございました。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?