1ファイルマッチ体験会反省会

先日からアナウンスしておりました1ファイルマッチ体験会が先週末無事終わりました。

報告が遅れましたが簡単にまとめておきます。

 

1ファイルマッチ体験会 - 48's diary

1ファイルマッチ体験会1 リーグ表

1ファイルマッチ体験会1 勝敗表

 

10チームで総当たり、9回裏表の計18回戦となりました。

上位は全勝の地ビール、15勝3敗のshogi686_sdt5、13勝4敗1分の真れさ改1ファイル、13勝5敗のsample4-5aと手前味噌ですがうちの子らが上位を占めてしまいました。

真れさ改もノーマルれさ改に比べ相当強かったですね。作者の池さんもバグがあるのは分かっているのでそのうち修正したいと仰っていたので修正されたのかもしれません。

 

うちの子らの順位はもう少し分散すると思っていたのですが、ノーマルれさ改の位置から考えても皆さんもう少し頑張りましょうといったところでしょうか。

floodgateレートで地ビールは2500くらい、sample4-5aが1500くらいですので目標にして頂ければと思います。LesserKaiは700くらいでしたっけ?

 

sample4-5aの方は過去のサンプル含めて公開しておきます。

shogi-eval/sample4-5a.py at master · bleu48/shogi-eval · GitHub

 

ただ、これはプログラム簡便のため探索深さ固定となっており、持ち時間が少なくなっても慌てて指すことはなく時間切れをします。

真面目に大会に出るなら対応しましょう。

 

本体験会でもsample4-5aの時間切れ負けは2度あり、特に9回表の時間切れの対応が遅れたために9回裏の開始に間に合わず異常負けとなったところです。

対戦エンジン的には9回裏の負け後に9回表の最後の指し手を返しておりました。酷いですね。

 

簡単にsample4-5aの改善点を説明しておきます。

探索部は体験会前に公開したネガアルファ法です。

 

アルファベータ法で結構探索時間に効いて来るのが候補手のオーダリングです。

今回簡単に実装してみました。(雑すぎると言われそう)

以前探索前に手を選ぶので駒を取る手や成る手を上位にする話がありましたが、同じ考え方を使ったオーダリングです。Pythonでリストの並べ替えはラムダ式を使えば簡単に一行で書けます。

    legal_moves = list(board.legal_moves) # いわゆる合法手リスト
    legal_moves = sorted(legal_moves, reverse=True, key=lambda x:x & 0b111100000100001110000000) # 取る駒,成るフラグ,打ち駒の部分をフィルタして逆ソート
    for m in legal_moves:

 

次の肝は評価関数です。

以前からサンプルを出していますがNNUE型のファイルを直接読んでいます。

今回は多方面で愛用されておりますKristallweizenを用いました。

デフォルトのモデル型なら以下のままで読めるはずです。

nn_data = open("eval/nn.bin", "rb").read()
i = 178 # NNUE型の評価関数の特徴表示文字列長(デフォルト値)
bias1 =  array( unpack_from("<"+str(256)+"h", nn_data, 16+i) )
weight1 = array( unpack_from("<"+str(256*125388)+"h", nn_data, 16+i+256*2) ).reshape(125388,256)
bias2 =  array( unpack_from("<"+str(32)+"i", nn_data, 16+i+2*(256+256*125388)+4) )
weight2 = array( unpack_from("<"+str(512*32)+"b", nn_data, 16+i+2*(256+256*125388)+4 + 4*32) ).reshape(32,512)
bias3 =  array( unpack_from("<"+str(32)+"i", nn_data, 16+i+2*(256+256*125388)+4+ 4*32+32*512) )
weight3 = array( unpack_from("<"+str(32*32)+"b", nn_data, 16+i+2*(256+256*125388)+4+ 4*32+32*512 + 4*32) ).reshape(32,32)
bias4 = unpack_from("<"+str(1)+"i", nn_data, 16+i+2*(256+256*125388)+4+ 4*32+32*512 + 4*32+32*32)[0]
weight4 = array( unpack_from("<"+str(32*1)+"b", nn_data, 16+i+2*(256+256*125388)+4+ 4*32+32*512 + 4*32+32*32 + 4*1) )

 

そして盤面評価ですね。

特徴ベクトルは他の関数で作っています。k0、k1は自玉敵玉の位置、fv38は玉以外の駒の位置、fc38qはそれを相手から見たものです。

これらが入力となり、あとはnumpyを使った簡単なニューラルネットワーク構成です。

つまり、差分更新やSIMD演算などのNNUEの特徴は生かしていません。

但し入力ベクトルが81×1548の長大なサイズに対して38個所に1が立つだけのものですので,これを38巡のループにする程度の処理をしています。

def eval(board): # NNUE型の評価関数(キャッシュや差分更新など無し)
    k0, k1, fv38, fv38q = fv40(board) # 特徴ベクトルの取得
    x = bias1.copy() # 手番側特徴
    for j in fv38:
        x += weight1[k0*1548 + j] # 手番側一層目
    x2 = bias1.copy() # 相手番特徴
    for j in fv38q:
        x2 += weight1[k1*1548 + j] # 相手番一層目
    x = append(x,x2).clip(0,127) # 結合してクリップ(Clipped ReLU)
    x = ( (bias2 + weight2.dot(x) ) // 64).clip(0, 127) #二層目
    x = ( (bias3 + weight3.dot(x) ) // 64).clip(0, 127) #三層目
    x = bias4 + weight4.dot(x) #四層目
    return (x // 16)

 

一応お断りをしておきますと、PythonC++では負の整数除算が異なりますので正確にはNNUEと同じ値になるわけではないです。オーダリングの雑さ加減から比べたら可愛いものです。気にしないでいいと思います。

 

次のイベントまでに切れ負けしないような処置と探索部の工夫を加えようかとは考えています。

あ、それから重要な反省点ですが、新参者歓迎です。

気楽にプログラム作ってみてください。

 

---

追記:

sample4-5a等のファイル名については適当に更新しているので細かく気にしないで下さい。一般的なソフトウェア開発と異なり、新旧バージョンの比較対戦などを頻繁に行うためにgit管理などが適さないためどんどんファイル名を付けていっているだけです。

4も探索深さ4固定の意味でもありますが、また変わるかもしれません。

---

追記2:

numpyの処理はnumpyのバージョンやCPUアーキテクチャによって結構処理速度が代わることが確認されています。もちろんnumpyのバージョンは新しい方が良く,CPUは廉価化したZen3や最新のZen4などAMDが何故かコストパフォーマンスが良い感じです。

手元計測ではRyzen 5 5625Uで12knps程度,Ryzen 9 7950Xで20knps程度は出るようです。インテルは最新世代のものでこれらの間の値です。

CPUもPythonもnumpyも年々高速化されていますので来年にはもっともっと速くなっているかもしれません。

もちろん,地ビールはGo言語実装ですのでこれより一桁以上高速で,やねうら王やshogi686_sdt5などの高度なC++実装は更に数倍速いです。