TadaoYamaokaの開発日記

個人開発しているスマホアプリや将棋AIの開発ネタを中心に書いていきます。

将棋AIの実験ノート:方策の分布を学習 その3

前回の続き。
dlshogiの強化学習で、方策の分布を含む棋譜を生成し、そのデータを用いてモデルの学習を行い、方策の分布の有無による精度の違いを検証した。

棋譜生成

dlshogiの自己対局で、200万局面(29315棋譜)を生成した。

学習条件

Resnet10ブロック、活性化関数Swishのモデルを、初期値から学習した。
データ数が少ないためSWAはオフにした。
方策の分布なしの条件は、現在のdlshogiで使用しているActor-Criticの学習則を使用した。

比較結果

訓練損失は、損失関数が異なるため、比較できないため、テスト損失と正解率を比較する。
テストデータには、2017年から2018年6月のfloodateのレート3500以上の棋譜を使用した。

テスト損失
条件 方策損失 価値損失
方策分布なし 1.2161698 0.62816209
方策分布あり 1.0862799 0.61040915
正解率
条件 方策正解率 価値正解率
方策分布なし 0.33065866 0.63345293
方策分布あり 0.35721064 0.65183012

エントロピー

条件 方策エントロピー 価値エントロピー
方策分布なし 2.03303697 0.59086932
方策分布あり 2.03077541 0.58158615

考察

以前にAobaZeroの棋譜を教師ありで学習したときと同様に、方策の分布を学習した方が、方策、価値ともに、精度が高くなった。
指し手のみを学習するより、ほぼ同じ訪問数2番目の手なども同時に学習できるため、サンプル効率が高くなったと考える。

まとめ

これまでdlshogiでは、指し手のみを学習していたが、AlphaZeroのように方策の分布を学習した方がよいか疑問に思っていた。
AlphaZeroの論文では、AlphaGoでは打ち手を学習していたところ、方策の分布を学習するように変更した理由について記述がなかったが、方策の分布を学習した方が効率が良いことを今回の実験で確かめることができた。

新しい方式に切り替えてもモデルは共通なので、これまでのモデルを活かすことができる。
特にデメリットもないので近々切り替える予定である。

将棋AIの実験ノート:方策の分布を学習 その2

以前に、指し手を学習するより、方策の分布を学習した方が、方策の精度が上がるということを書いた。
しかし、現在、dlshogiの強化学習で生成している教師局面フォーマット(hcpe)では、方策の分布を記録していない。

そこで、方策の分布を出力できるように、教師データのフォーマットを見直した。

フォーマット

今までは、局面単位に出力して、不要な局面(合法手が1手しかない局面)は、出力していなかった。
また、対局の境目が明確にわからないようになっていた。
そのため、自己対局の結果を棋譜として確認することが難しかった。
固定長フォーマットで、シャッフル可能で、データサイズを小さくするということを要件してしていたためである。

今回見直すにあたり、方策の分布を記録するため可変長になるため、棋譜にも変換できるフォーマットにすることにした。
開始局面から、指し手と方策の分布をシーケンシャルに記録していく形にする。

AobaZeroは、CSAフォーマットにコメントで方策の分布を記録しているが、冗長でファイルサイズが膨らむため、バイナリフォーマットとした。

バイナリ棋譜フォーマット(hcpe3)
HuffmanCodedPosAndEval3 = np.dtype([
    ('hcp', dtypeHcp), # 開始局面
    ('moveNum', np.uint16), # 手数
    ('result', np.uint8), # 結果(xxxxxx11:勝敗、xxxxx1xx:千日手、xxxx1xxx:入玉宣言、xxx1xxxx:最大手数)
    ('opponent', np.uint8), # 対戦相手(0:自己対局、1:先手usi、2:後手usi)
    ])
MoveInfo = np.dtype([
    ('selectedMove16', dtypeMove16), # 指し手
    ('eval', dtypeEval), # 評価値
    ('candidateNum', np.uint16), # 候補手の数
    ])
MoveVisits = np.dtype([
    ('move16', dtypeMove16), # 候補手
    ('visitNum', np.uint16), # 訪問回数
    ])

対局ごとにHuffmanCodedPosAndEval3を1つ出力し、1手ごとに1つのMoveInfoと、合法手の数分のMoveVisitsを出力する。

ノイズの影響の除去

AlphaZeroでは、自己対局時にルートノードにディリクレ分布のノイズを加えて、最善手だけではなく、新しい手も試すようにしている。
dlshogiでも、ルートノードにノイズを加えている。
(ただし、ディリクレ分布ではなく、ランダムに手の確率を1にした分布と方策の平均を使用している。)

ノイズにより選んだ手は、ほとんどの場合悪い手であるため、ノイズを含んだ分布を学習するのは方策の精度に悪影響がでる可能性がある。
そこで、ノイズがない場合に選択した手とノイズを加えたことにより選択した手が異なる場合に、その手の訪問回数から減算することにした。
これは、KataGoからヒントを得たアイディアである。
参考:[1902.10565] Accelerating Self-Play Learning in Go 3.2

AobaZeroとの相互運用

バイナリ棋譜フォーマット(hcpe3)から、AobaZeroの形式にも変換できるスクリプトを作成した。
DeepLearningShogi/hcpe3_to_csa.py at feature/hcpe3 · TadaoYamaoka/DeepLearningShogi · GitHub

また、AobaZeroの棋譜から、バイナリ棋譜フォーマット(hcpe3)に変換できるスクリプトを作成した。
DeepLearningShogi/aoba_to_hcpe3.py at feature/hcpe3 · TadaoYamaoka/DeepLearningShogi · GitHub

まとめ

指し手を学習するより、方策の分布を学習する方が精度が上がる見込みがある。
そのため、自己対局で方策の分布を出力できるようにした。

dlshogiの自己対局で新フォーマットのデータを生成して、方策の分布の有無による比較を別途行う予定。

将棋AIの進捗 その56(データローダーの並列化)

dlshogiのモデルの訓練に使用しているPythonスクリプトは、ベタなforループで記述しており、ミニバッチ作成部分と、ニューラルネットワークの学習の処理をシーケンシャルに実行しており並列化は行っていなかった。

ミニバッチデータの作成は、盤面から入力特徴量を作成する処理が比較的重いため、C++で実装して高速化している。
それでも、ある程度CPU時間を消費している。

一方ニューラルネットワークの学習処理は、GPUで処理しているため、CPUは遊んでいる状態になる。
その間に、次のミニバッチデータの作成を行えば、よりCPUの使用効率を上げることができる。

PyTorchには、データローダーを並列化する仕組みがあるが、マルチプロセスで実装されており、Windowsの場合は、プロセスにデータをpickleで送信する必要があり、データ量が大きいと効率が上がらない。
torch.utils.data — PyTorch 1.10 documentation
なお、Linuxの場合は、マルチプロセスはfork()で実装されているため、プロセス間でメモリが共有されるため転送がいらない。

そこで、Windowsでも高速化できるように、マルチスレッドのデータローダーを実装した。

実装方法

学習処理の間にミニバッチの作成を別スレッドで行うようにする。
実装は単純で、以下のように処理する。

  1. ミニバッチを作成した後に、次に作成するミニバッチを別スレッドで作成開始するようにする(pre fetch)。
  2. 次にミニバッチが必要になったタイミングでは、別スレッドで作成していたデータが作成済みになっているのでそれを取得する。
  3. 以上を繰り返す。

別スレッドでの実行と、完了を待つ処理は、PythonのThreadPoolExecutorを利用して、Futureパターンで実装した。
別スレッドで実行中に、GILを解放しないと、Pythonではマルチスレッドの効果がでないため、C++側でGILを解放する処理を入れた。


また、今までミニバッチ作成の度、メモリをnp.emptyで確保していたが、データローダ作成時に一回作成してそれを使いまわすようにした。
その際、pin_memoryも有効にした。

測定結果

変更前後で、学習時間は以下の通りになった。

  • 1,439,175局面の学習時間を測定
  • SWAのbn_updateとテストの時間含む
時間(時:分:秒)
変更前 0:06:33 100%
変更後 0:04:47 73.0%

学習時間を73%に短縮することができた。

なお、np.emptyで毎回メモリ確保するのをやめるだけでも、学習時間は84%になった。
メモリ確保も比較的重かったようだ。

まとめ

Windowsでも高速で動くマルチスレッドのデータローダを実装した。
その結果、学習時間を73%に短縮することができた。

AobaZeroの棋譜の統計情報

AobaZeroの棋譜を利用するにあたり、統計的な傾向を把握しておきたいと思い調べてみた。

調査範囲

arch000012000000.csa.xz ~ arch000026050000.csa.xzの棋譜を調べた。
1ファイル当たり1万棋譜含まれ、棋譜数は合計で14,050,000になる。

手数

手数を10手間隔のヒストグラムにした(100万棋譜単位の積み上げ)。
f:id:TadaoYamaoka:20210227164152p:plain
※系列のgroupは棋譜棋譜のファイル名の数値部分の100万で割った値

120手あたりにピークがある。
新しい棋譜ほど手数がわずかに短くなる傾向がある。

終局状況

投了、千日手入玉宣言勝ち、最大手数で中断、反則の数を、100万棋譜単位の積み上げグラフにした。
f:id:TadaoYamaoka:20210227164057p:plain

終局状況 合計 割合
%TORYO 12382954 88.13%
%SENNICHITE 1337706 9.52%
%KACHI 325071 2.31%
%CHUDAN 1149 0.01%
%+ILLEGAL_ACTION 2175 0.02%
%-ILLEGAL_ACTION 945 0.01%

88%が投了で終局している。
最大手数(512)で引き分けの数はほとんどなかった。

入玉宣言勝ちの数

1万棋譜ごとの入玉宣言勝ちの数を時系列で調べた。
f:id:TadaoYamaoka:20210227165621p:plain

arch000019000000あたりから大きく傾向が変わっている。
これは、AobaZeroのバージョンアップで、勝率10%で投了するようにしたことが原因と思われる。

千日手の数

1万棋譜ごとの千日手の数を時系列で調べた。
f:id:TadaoYamaoka:20210227165340p:plain

増えたり減ったりと変動しているが、理由は分からない。

詰みの見逃しの数

AobaZeroは簡単な詰みを見逃す傾向がある。
どれくらい見逃しているか確認した。

1万棋譜ごとの5手詰めを見逃した数を時系列で調べた(50手以上の棋譜を調査)。
f:id:TadaoYamaoka:20210227222757p:plain

arch000019000000あたりから減っているのは、投了の閾値が原因と思われる。
arch000022000000あたりからさらに減っているのは、投了の勝率が自動調整になったからだと思われる。

手数が50手以上の棋譜で、見逃しは投了扱いとした場合の、各終局状況の数と詰みを見逃して逆転した数の合計は以下の通り。

終局状況 合計 割合
投了 10948372 87.19%
千日手 1286463 10.24%
入玉宣言勝ち 321708 2.56%
中断 532 0.00%
詰み見逃しで逆転 49228 0.39%

0.39%の棋譜で5手詰みを見逃して逆転が発生している。

まとめ

AobaZeroは入玉宣言勝ちを目指す棋風に特徴がある。
棋譜を調べた結果、入玉宣言勝ちの棋譜は、全体の2.31%含まれていた。
dlshogiの自己対局では、初期局面集を使っているため直接比較できないが、直近の90456対局で調べたところ、0.46%である。
dlshogiと比べると、入玉宣言勝ちの棋譜の割合は多い。
入玉宣言勝ちの初期局面集を作ったり、入玉宣言の学習に、AobaZeroの棋譜は利用価値が高い。
ただし、arch000019000000あたりから大幅に数が減っているので、それ以前の棋譜から抽出が必要である。
(投了の閾値がない方が入玉宣言勝ちの棋譜が増えて利用価値が大きかったので戻して欲しい・・・(個人的感想))


一方、AobaZeroは、詰まして勝つ将棋には弱い傾向がある。
棋譜を調べたところ、0.39%の棋譜で5手詰めを見逃して逆転していた。
(※はじめ55%と記したが、逆転が条件になっていなかったため修正した。)

調査に使ったソース

DeepLearningShogi/aoba_to_hcpe2.py at feature/hcpe2 · TadaoYamaoka/DeepLearningShogi · GitHub

グラフ化のソース
import pandas as pd

df = pd.read_csv('stat.csv')
df['group'] = df['name'].apply(lambda x: x[8:10])

df.groupby('group')[[str(i) + '.0' for i in range(0, 520, 10)]].sum().T.plot.area(grid=True)
plt.xticks(range(0, 52, 5))

df.groupby('group')[['%TORYO', '%SENNICHITE', '%KACHI', '%CHUDAN', '%+ILLEGAL_ACTION', '%-ILLEGAL_ACTION']].sum().T.plot.bar(stacked=True, grid=True)

df[['group', '%KACHI']].plot(x='group', grid=True)
df[['group', '%SENNICHITE']].plot(x='group', grid=True)
df[['group', 'minogashi']].plot(x='group', grid=True)

将棋AIの実験ノート:初期局面集の優先順位付きサンプリング

深層強化学習の手法に、Prioritized experience replay (PER)(優先順位付き経験再生)という方法がある。
リプレイバッファに蓄積した経験データに優先順位を付けて、優先順位が高いほどより多くサンプリングする手法だ。

優先順位の尺度には、TD誤差が用いられる。
現在のネットワークの予測と、nステップ後の\max _{a} Q\left(s^{\prime}, a\right)の差が大きいほど優先的にサンプリングする。
サンプリングしたデータの優先度は、その時点の推論結果によって更新される。

将棋AIへの応用

この考えを、dlshogiの学習に採用したいと思っているが、将棋AIでは、勝敗と探索後のルートノードQ値を学習しているため、一度付けた優先度を推論結果だけでは更新することができない。
そこで、優先順位を付けるのを初期局面のサンプリングに置き換えることで、似たような効果を得たいと考えている。
つまり、判断の難しい局面(=優先度が高い局面)を多く初期局面として対局することで、学習効果の高い棋譜を生成したい。

以前に、自己対局で優勢と判断した局面から負けた局面を抽出して、初期局面集に加えるということを行ったが、考え方は近い。
しかし、もう少し統計的に処理したい。

実現方式案

初期局面集に登録する局面には、勝敗結果とその時の探索結果のQ値を記録しておく。

初期局面集をロードする際に、|勝敗結果-Q値|を使用してsum-treeを構築する。
sum-treeはセグメント木の一種で、一度構築すると、優先度によるサンプリングがO(log(N))の計算量で行える。

優先度には、|勝敗結果-Q値|以外にも、序盤や入玉勝ちを重点的に学習したい場合にバイアスを掛けたりできる。
そのため初期局面集には、勝敗の理由(千日手入玉勝ち)や、手数も同時に記録しておくと良いだろう。

こうしておけば、過去の自己対局で生成した局面や、floodgateの棋譜などから適当にサンプリングして登録した初期局面集でも、適切な優先度に応じて初期局面として使用されるため、初期局面の作成に悩まずに済む。
とにかく数多く登録しておけばよい。

まとめ

まだ試しても実装もしていないが、とりあえず案だけ書いてみた。
教師局面のフォーマットを見直そうと考えているので、後で必要になりそうな情報も出力しておきたいため、とりあえず案だけ整理してみた形である。

MineRLをWindowsで動かす

MinecraftのOpenAI Gym環境 MineRLWindowsで動かすための環境構築方法について。
ほぼ個人用メモ。

インストール方法は、公式ドキュメントを元にしている。

OpenJDKインストール

Chocolateyを使用してインストールする。
PowerShellを管理者権限で起動し、

choco install openjdk8

Python環境構築

Anaconda3の仮想環境に構築する。
Pythonのバージョンは、3.8だとコンフリクトが起きるため、3.7を使用する。
依存パッケージは、condaでインストールしておく。

conda create -n minerl python=3.7 matplotlib=3.0.3 pillow=7.2.0 jupyter coloredlogs numpy scipy scikit-learn pandas tqdm joblib requests lxml psutil dill future cloudpickle typing
conda activate minerl
conda install pytorch torchvision torchaudio cudatoolkit=11.0 -c pytorch

matplotlibはバージョン指定(3.0.3)になっているので注意。
minerl/requirements.txt at 5a9c06a7e727ae20cf123e5161b5cfa6ba69793b · minerllabs/minerl · GitHub
異なるバージョンだと、ソースからビルドされて、Windows環境だとビルドエラーになる。

MineRLインストール

pip install minerl==0.3.6

バージョンは現時点の最新。

起動テストとgradle修正

iPythonやJupyter QtConsoleなどから、以下のスクリプトを実行する。

import minerl
import gym
env = gym.make('MineRLNavigateDense-v0')

数分待つと、以下のようなビルドエラーが発生する。

* What went wrong:
Could not resolve all dependencies for configuration ':compileClasspath'.
> Could not resolve org.spongepowered:mixin:0.7.5-SNAPSHOT.
  Required by:
      com.microsoft.MalmoMod:Minecraft:0.37.0
   > Could not resolve org.spongepowered:mixin:0.7.5-SNAPSHOT.
      > Unable to load Maven meta-data from http://repo.spongepowered.org/maven/org/spongepowered/mixin/0.7.5-SNAPSHOT/maven-metadata.xml.
         > Could not GET 'http://repo.spongepowered.org/maven/org/spongepowered/mixin/0.7.5-SNAPSHOT/maven-metadata.xml'. Received status code 520 from server: Origin Error

gradleの定義が古く、URLが間違っているため、以下のファイルをテキストエディタで開き、手動で編集する。
C:\Users\<username>\anaconda3\envs\minerl\Lib\site-packages\minerl\env\Malmo\Minecraft\build.gradle

(変更前)

url = "http://repo.spongepowered.org/maven/"
(変更後)
url = "https://repo.spongepowered.org/maven/"

参考:assanuma Profile - githubmemory

起動テスト

以下のスクリプトを実行する。

import minerl
import gym
env = gym.make('MineRLNavigateDense-v0')

MineCraftが起動する。
f:id:TadaoYamaoka:20210223091003p:plain

obs = env.reset()

を実行すると、ゲームが開始し、小さいウィンドウになる。
f:id:TadaoYamaoka:20210223091056p:plain

f:id:TadaoYamaoka:20210223091231p:plain

env.render()

を実行すると、別ウィンドウで、拡大画面と現在の状態が表示される。
f:id:TadaoYamaoka:20210223091328p:plain

サンプル実行

ランダムに行動するサンプルプログラムを実行する。

import minerl
import gym
env = gym.make('MineRLNavigateDense-v0')

obs = env.reset()

done = False
while not done:
    action = env.action_space.sample()
    obs, reward, done, info = env.step(action)

ランダムにアクションをサンプリングし、アクションがなくなるまで実行される。
カメラ操作もランダムなので、スムーズな表示ではない。

カメラをゆっくり操作して、大きい画面でウェイトを入れて表示してみる。

import minerl
import gym
from time import sleep
env = gym.make('MineRLNavigateDense-v0')

obs = env.reset()

done = False
while not done:
    action = env.action_space.sample()
    action['camera'] = [0, min(5, max(-5, action['camera'][1]))]
    obs, reward, done, info = env.step(action)
    env.render()
    sleep(0.01)
終了方法
env.close()

で、終了する。

将棋AIの実験ノート:Normalizer-Free Networks

ちょうどFixup Initializationを試したタイミングで、DeepMindからBatch Normalizerを削除してSOTAを達成したという論文が発表された。

さっそく試してみようとしたが、ソースコードJaxで実装されているため、PyTorchで実装し直す必要がある。
deepmind-research/nfnets at master · deepmind/deepmind-research · GitHub

はじめ自力で実装していたが、途中で非公式のPyTorchの実装が公開されたたため、そちらを大いに参考にさせてもらった。
pytorch-image-models/nfnet.py at master · rwightman/pytorch-image-models · GitHub

ひとまず実装できたので、現在のdlshogiのResNet(Batch Normalizerあり)と、Normalizer-Free ResNetsで比較してみた。

論文の要旨

Batch Normalizerの効果
  • 損失を滑らかにし、より大きな学習率とより大きなバッチサイズで安定した訓練を可能とする。
  • 正則化の効果もある。
Batch Normalizerの欠点
  • 驚くほど高価な計算プリミティブであり、メモリオーバーヘッドが発生し、一部のネットワークで勾配を評価するために必要な時間が大幅に増加する。
  • 訓練中と推論時のモデルの動作の間に不一致が生じ、調整が必要な隠れたハイパーパラメーターが導入される。
  • ミニバッチの訓練サンプル間の独立性を壊す。
提案手法
  • ネットワーク構成は、Normalizer-Free ResNets(NF-ResNets)をベースにする。残差ブロックの分散の増加に合わせてダウンスケールする。

[2101.08692] Characterizing signal propagation to close the performance gap in unnormalized ResNets

  • 残差ブロックの最後に、学習可能なスカラー(SkipInit)を含める。
  • 畳み込み層の重みの平均と分散により、再パラメータ化する「Scaled Weight Standardization」を導入する。
  • 従来の勾配クリッピングは、閾値の選択に敏感であるため、重みのノルムによる比を用いた「Adaptive Gradient Clipping (AGC) 」を導入する。

\displaystyle
\begin{equation}
G^{\ell}_i \rightarrow
    \begin{cases}
    
    \lambda \frac{\|W^{\ell}_i\|^\star_F}{\|G^{\ell}_i\|_F}G^{\ell}_i& \text{if $\frac{\|G^{\ell}_i\|_F}{\|W^{\ell}_i\|^\star_F} > \lambda$}, \\
    G^{\ell}_i & \text{otherwise.}
    \end{cases}
\end{equation}

実験方法

dlshogiの強化学習で生成した31,564,618局面で訓練し、floodgateからサンプリングした856,923局面で評価する。
MomentumSGD(lr=0.1)で学習する。
SWAはオフにする。
重み減衰は有効(rate=0.0001)にする。
論文の残差ブロックの畳み込み層は3層だが、dlshogiは2層なので2層に合わせる。
活性化関数は論文ではgeluだが、dlshogiに合わせてsiluを使用する。
論文では、AGCは、最終層には適用しないとあるので、policyヘッドと、valueヘッドの全結合層は除外する。

測定結果

f:id:TadaoYamaoka:20210220155549p:plainf:id:TadaoYamaoka:20210220155552p:plainf:id:TadaoYamaoka:20210220155555p:plainf:id:TadaoYamaoka:20210220155559p:plain

考察

Batch Normalizerを用いたResNetの方が、速く訓練損失が低下しており、テスト損失も低い。
NFNetsには、いくつかパラメータがあるため、いくつか変更して試してみたが、ほとんど結果は変わらなかった。

まとめ

Normalizer-Free Networksを、dlshogiの学習で試してみた。
論文ではImageNetの学習で、SOTAを達成したと報告されているが、将棋の学習では効果を確認できなかった。

画像のデータセットで実装したコードを使用してそもそも再現できるのかや、論文と残差ブロックの畳み込み層の数が異なるなど条件が異なる部分もあるため、そろえた場合にどうなるかは別途確認したい。

学習時間

学習時間は以下の通りであった。
PyTorchでAMPを使用している。

訓練時間
ResNet(Batch Normalizerあり) 1:16:34 100%
NFNets 1:09:05 90.2%