TadaoYamaokaの日記

山岡忠夫Homeで公開しているプログラムの開発ネタを中心に書いていきます。

cshogiにリーグ戦モードを追加

プログラムの修正やモデルを学習した後の強さの計測に変更前後の自己対戦のみだと、系統が違うソフトに対して強くなっていないことがあるため、基準となるソフトを加えたリーグ戦で確認を行っている。

連続対局には、cshogiを使用して、PGNファイルを出力して、Ordoでeloレーティングを計算している。
今までcshogiは2つのソフトの連続対局しか対応していなかったため、マシンやプロセスを分けて実行していた。
スペックが同じでもマシンや使用するGPUが変わると、誤差の原因になるため、3つのソフトを指定してリーグ戦で対局できるようにした。
リーグ戦を並列で実行すれば、同じハードを均等に使うので、誤差が出にくい。
また、各ソフトの対局数が同じになるので、途中経過を確認しやすい。


cshogiの使い方についてあまりドキュメントを書いていないので、使い方を書いておく。

cshogiのインストール

pip install cshogi

アップデートの場合は、

pip install cshogi -U

連続対局の実行方法

コマンド例
python -u -m cshogi.cli /work/DeepLearningShogi/usi/bin/usi /work/DeepLearningShogi/usi/bin/usi /work/YaneuraOu/bin/YaneuraOu-by-gcc --name1 model1 --name2 model2 --options1 Draw_Ply:320,OwnBook:false,PV_Interval:0,DNN_Model:/work/model/model1.onnx,UCT_Threads:2 --options2 Draw_Ply:320,OwnBook:false,PV_Interval:0,DNN_Model:/work/model/model2.onnx,UCT_Threads:2 --options3 USI_Hash:2048,Threads:2,PvInterval:9999,NetworkDelay:0,NetworkDelay2:0,ResignValue:10000,MaxMovesToDraw:320 --opening /work/taya36.sfen --draw 320 --time 180000 --inc 1000 1000 1500 --games 128 --pgn model1_vs_model2_time180inc1-01.pgn > log_model1_vs_model2_time180inc1-01.txt &

エンジンは、位置引数に2つもしくは3つ指定できる。
2つの場合は1対1で先後入れ替えて連続対局を行い、3つの場合はリーグ戦になる。
リーグ戦は、先後入れ替えて1局ずつを3ソフト全組み合わせに対し行うことを繰り返す。

同一ソフトで、パラメータのみ変える場合は名前が被るため「--name1 」などで名前を上書きする。「1」の部分が引数の1番目のエンジンであることを示している。
USIオプションは、「--options1」などで指定する。

「--opening」で開始局面集を指定する。
デフォルトでランダムでサンプリングするが、「--opening-seed 0」のようにシードを固定できる。
「--opening-moves」で開始局面集の何手目から開始するか指定できる。

「--draw」で引き分けとする手数を指定できる。

「--time」と「--inc」で、持ち時間と1手加算時間をミリ秒単位で指定する。
エンジンごとに値を分ける場合は、エンジンの数だけ複数指定する。

秒読みの場合は「--byoyomi」で秒読み時間をミリ秒単位で指定する。
こちらも複数指定することでエンジンごとに値を分けることができる。

「--resign」で投了の閾値を設定できる。例)--resign 3000
「--mate_win」を指定すると、手番側の詰みを見つけた時点で終了する(手番側の詰みの探索を信用する)。

「--games」で対局数を指定する。

「--pgn」でPNGの出力パスを指定する。

「--csa」を指定すると、指定したフォルダにCSAファイルを出力する。
「--multi_csa」を指定すると複数対局を1ファイルに出力する。

「--keep_process」を指定すると、1局ごとにエンジンのプロセスを終了しない(リーグ戦の場合は、先後入れ替えのタイミングのみ)。

「--debug」を指定すると、USIコマンドとその応答をすべて標準出力に出力する。

対局の結果は、標準出力に出力される。
ファイルに出力したい場合は、リダイレクトする。
リダイレクトすると、バッファリングされるため、pythonのオプションに「-u」を付けると良い。

eloレーティングの計算

PGNファイルをOrdoに渡すことでeloレーティングが計算できる。

Ordoの実行例
ordo-win64.exe -Q -D -a 0 -W -n8 -s1000 -U "0,1,2,3,4,5,6,7,8,9,10" -p model1_vs_model2_time180inc1-01.pgn

複数プロセスで並列実行した場合は、複数PGNファイルを入力できる。

ordo-win64.exe -Q -D -a 0 -W -n8 -s1000 -U "0,1,2,3,4,5,6,7,8,9,10" -p model1_vs_model2_time180inc1-01.pgn -- model1_vs_model2_time180inc1-02.pgn model1_vs_model2_time180inc1-03.pgn

以下のように表示される。

0   10   20   30   40   50   60   70   80   90   100 (%)
|----|----|----|----|----|----|----|----|----|----|
***************************************************

   # PLAYER                        :  RATING  ERROR  POINTS  PLAYED   (%)  CFS(%)    W    D    L  D(%)
   1 model_gct_010_opt             :    31.3   31.7   116.0     205    57      77  110   12   83     6
   2 model-0000195                 :    10.5   32.0   107.0     205    52      97  100   14   91     7
   3 YaneuraOu NNUE 6.00 64AVX2    :   -41.8   31.3    84.0     204    41     ---   80    8  116     4

White advantage = 11.75 +/- 19.12
Draw rate (equal opponents) = 5.61 % +/- 1.32

レーティングの誤差と、どれくらいの確かさで強いかを表すCFS(%)も表示されるため、有意差があるか確認できる。

将棋AIの実験ノート:方策の分布を学習すると探索パラメータの調整が必要になる

以前に方策の分布を学習することで、Actor-Criticで学習するよりも精度が上がることを確かめた。
dlshogiの強化学習でも、方策の分布を学習するように移行した。

しかし、テストデータに対する精度は上がるが、実際に対局すると弱くなっているという問題が発生した。

精度の比較

すでに精度が飽和しているモデルに対して、1億局面を生成し方策の分布を学習すると以下のように精度が大幅に向上した。

テストデータに、floodgateのレート3500以上の対局の棋譜からサンプリングした856,923局面を使用して評価した結果は以下の通り。
※このテストデータは、GCTのノートブックで公開しているテストデータとは別である。

テスト損失
方策の損失 価値の損失 Q値の損失 損失合計
学習前 0.83306111 0.51837930 0.63040887 1.38874626
学習後 0.76382181 0.50587031 0.63739542 1.31348998
テスト正解率
方策の正解率 価値の正解率
学習前 0.47950766 0.73489700
学習後 0.48287093 0.73816614
テストエントロピー
方策のエントロピー 価値のエントロピー
学習前 1.11967892 0.58347101
学習後 1.40010058 0.56201832

方策、価値の正解率も向上している。特に方策は大幅に向上した。
方策のエントロピーが上がっており、予測の確率分布が広がっていることが分かる。

強さの比較

持ち時間3分1手1秒加算

   # PLAYER           :  RATING  ERROR  POINTS  PLAYED   (%)  CFS(%)    W    D    L  D(%)
   1 model-0000195    :    13.8   15.8   233.0     432    54      96  215   36  181     8
   2 model_gct_010    :   -13.8   15.8   199.0     432    46     ---  181   36  215     8

※model_gct_010が方策の分布を学習したモデル

精度が上がったのに反して、弱くなっている。

考察

方策のエントロピーが上がっていることから、探索の幅が広がっていると考えられる。

初期局面を100万ノード探索した際の、PVの深さは

学習前 42
学習後 32

となっていた。

分布の広がりは、以下のようになっていた。

方策の確率分布

f:id:TadaoYamaoka:20210405213614p:plain

ルートの子ノードの訪問回数

f:id:TadaoYamaoka:20210405213617p:plain

初期局面だと少しわかりにくかったが、学習前は特定の手に集中しているのに対して、方策が全体に少し広がっているのが確認できる。

弱くなる原因

Actor-Criticは、方策が決定論的に偏りやすい傾向があるため、現在の探索パラメータの温度パラメータは1.74と高めに設定されている。
optunaで最適化を行った結果、それが最適になっている。

方策の分布を学習すると、方策の確率分布が広がるため温度パラメータは比較的低めに設定されるべきである。

なお、Leela Chess Zeroの温度パラメータは、1.4に設定されている。
https://training.lczero.org/training_runs

探索パラメータの再調整

方策の分布を学習後のモデルを使用して、探索パラメータを再調整を行った。
なお、optunaで少し探索を行っただけのため、まだ調整は不十分な状態である。

初期局面のPVの深さは、41になった。

学習前と学習後のモデルで対局した結果は、以下の通りとなった。
持ち時間3分1手1秒加算

   # PLAYER               :  RATING  ERROR  POINTS  PLAYED   (%)  CFS(%)    W    D    L  D(%)
   1 model-0000195        :     0.7   10.5   514.0    1024    50      55  470   88  466     9
   2 model_gct_010_opt    :    -0.7   10.5   510.0    1024    50     ---  466   88  470     9

ほぼ互角になっている。

水匠3改を加えてリーグ戦を行った結果は以下の通り(測定途中)。

   # PLAYER                        :  RATING  ERROR  POINTS  PLAYED   (%)  CFS(%)    W    D    L  D(%)
   1 model_gct_010_opt             :    30.6   59.1    35.5      63    56      65   33    5   25     8
   2 model-0000195                 :    11.1   56.8    33.5      64    52      86   32    3   29     5
   3 YaneuraOu NNUE 6.00 64AVX2    :   -41.7   57.5    26.0      63    41     ---   24    4   35     6

こちらの結果では、強くなっていそうである。

まとめ

方策の分布を学習することで、テストデータに対する精度があがるが、探索パラメータがそのままだと弱くなることがわかった。
原因は、Actor-Criticで学習したモデルと、方策の分布を学習したモデルで、最適な探索パラメータが変わるためである。
探索パラメータを再調整すると、強さも精度を反映した結果になる。

将棋AIの実験ノート:AVX対応

コンピュータチェスのCeresでは、PUCTによるノード選択の処理をAVXを使って高速化している。
これは、Ceres独自の「parallelized descent algorithm」(並列降下アルゴリズム)と合わせて使用することで、効果を発揮するもののようだ。

Ceresで実際にどれくらいNPSがでるか確認したところ、15ブロック、192フィルタのモデルを使用して、RTX30901枚で、初期局面のNPSは、142,685であった。

go nodes 1000000
Loaded network weights: 750181: 15x192 WDL MLH  from H:\src\Ceres\lc0\weights_run3_750181.pb.gz
info depth 1 seldepth 2 time 1016 nodes 1 score cp -0 tbhits 0 nps 1 pv g2g3  string M= 157
(略)
info depth 10 seldepth 22 time 7005 nodes 999471 score cp 8 tbhits 0 nps 142685 pv c2c4 e7e6 g2g3 d7d5 f1g2 d5d4 g1f3 c7c5 e2e3 b8c6 e3d4 c5d4 e1g1 g8f6 f1e1 f8d6 a2a3 a7a5 d2d3 e8g8 b1d2  string M= 157
bestmove c2c4

一方、dlshogiは、10ブロック、192フィルタのモデルで、初期局面のNPSは48,245である。
チェスと将棋の盤面サイズ、終端ノードでの詰み探索のありなどの条件の違いはあるが、Ceresはdlshogiより大きいモデルで約2.96倍のNPSが出ている。

dlshogiの探索部もまだまだ高速化の余地があることが分かる。

Ceresの並列アルゴリズムをdlshogiにも導入したいと考えているが、とりあえず、並列アルゴリズムは現在のままで、ノード選択処理のAVX化を行ってみた。

Ceresは並列アルゴリズムと組み合わせてAVX化しているが、単にノードを選択する際に、複数の子ノードのUCBを計算して、UCBが最大のノードを選ぶ処理をAVX化した。

AVX化前の処理

	for (int i = 0; i < child_num; i++) {
		const WinType win = uct_child[i].win;
		const int move_count = uct_child[i].move_count;

		if (move_count == 0) {
			// 未探索のノードの価値に、親ノードの価値を使用する
			q = parent_q;
			u = init_u;
		}
		else {
			q = (float)(win / move_count);
			u = sqrt_sum / (1 + move_count);
		}

		const float rate = uct_child[i].nnrate;
		const float ucb_value = q + c * u * rate;
		if (ucb_value > max_value) {
			max_value = ucb_value;
			max_child = i;
		}
	}

AVX化後の処理

const __m256i m256i_zero{};
const __m256i m256i_one = _mm256_set1_epi32(1);
const __m256 m256_one = _mm256_set1_ps(1);
const __m256i m256i_eight = _mm256_set1_epi32(8);

	__m256 m256_c = _mm256_broadcast_ss(&c);
	__m256 m256_parent_q = _mm256_broadcast_ss(&parent_q);
	__m256 m256_init_u = _mm256_broadcast_ss(&init_u);
	__m256 m256_sqrt_sum = _mm256_broadcast_ss(&sqrt_sum);

	__m256 vmaxvalue = _mm256_set1_ps(-FLT_MAX);
	__m256i vnowposition = _mm256_set_epi32(7, 6, 5, 4, 3, 2, 1, 0);
	__m256i vmaxposition = vnowposition;
	for (size_t i = 0; i < child_num; i += 8) {
		if (i + 8 > child_num) {
			// 残り8未満
			__m256i mask_rest;
			switch (child_num - i) {
			case 1:
				mask_rest = _mm256_set_epi32(0, 0, 0, 0, 0, 0, 0, -1);
				break;
			case 2:
				mask_rest = _mm256_set_epi32(0, 0, 0, 0, 0, 0, -1, -1);
				break;
			case 3:
				mask_rest = _mm256_set_epi32(0, 0, 0, 0, 0, -1, -1, -1);
				break;
			case 4:
				mask_rest = _mm256_set_epi32(0, 0, 0, 0, -1, -1, -1, -1);
				break;
			case 5:
				mask_rest = _mm256_set_epi32(0, 0, 0, -1, -1, -1, -1, -1);
				break;
			case 6:
				mask_rest = _mm256_set_epi32(0, 0, -1, -1, -1, -1, -1, -1);
				break;
			case 7:
				mask_rest = _mm256_set_epi32(0, -1, -1, -1, -1, -1, -1, -1);
				break;
			default:
				// unreachable
				mask_rest = _mm256_set1_epi32(0);
				break;
			}
			__m256i m256i_move_count = _mm256_maskload_epi32(move_count + i, mask_rest);
			__m256i mask = _mm256_cmpgt_epi32(m256i_move_count, m256i_zero);

			//	q = (float)(win / move_count);
			__m256 m256_win = _mm256_maskload_ps(win + i, mask_rest);
			__m256 m256_move_count = _mm256_cvtepi32_ps(m256i_move_count);
			__m256 m256_q_tmp = _mm256_div_ps(m256_win, m256_move_count);
			__m256 m256_q = _mm256_blendv_ps(m256_parent_q, m256_q_tmp, _mm256_castsi256_ps(mask));

			//	u = sqrt_sum / (1 + move_count);
			__m256 m256_move_count_plus1 = _mm256_add_ps(m256_move_count, m256_one);
			__m256 m256_u_tmp = _mm256_div_ps(m256_sqrt_sum, m256_move_count_plus1);
			__m256 m256_u = _mm256_blendv_ps(m256_init_u, m256_u_tmp, _mm256_castsi256_ps(mask));
			__m256 m256_rate = _mm256_maskload_ps(nnrate + i, mask_rest);

			//const float ucb_value = q + c * u * rate;
			__m256 m256_ucb_value = _mm256_mul_ps(m256_c, m256_u);
			m256_ucb_value = _mm256_mul_ps(m256_ucb_value, m256_rate);
			m256_ucb_value = _mm256_add_ps(m256_q, m256_ucb_value);

			// mask
			m256_ucb_value = _mm256_and_ps(m256_ucb_value, _mm256_castsi256_ps(mask_rest));

			// find max
			__m256 vcmp = _mm256_cmp_ps(m256_ucb_value, vmaxvalue, _CMP_GT_OS);
			vmaxvalue = _mm256_max_ps(m256_ucb_value, vmaxvalue);
			vmaxposition = _mm256_blendv_epi8(vmaxposition, vnowposition, _mm256_castps_si256(vcmp));
			vnowposition = _mm256_add_epi32(vnowposition, m256i_eight);

			break;
		}

		//if (move_count == 0) {
		__m256i m256i_move_count = _mm256_load_si256((__m256i*)(move_count + i));
		__m256i mask = _mm256_cmpgt_epi32(m256i_move_count, m256i_zero);

		//	// 未探索のノードの価値に、親ノードの価値を使用する
		//	q = parent_q;
		//	u = init_u;
		//  --> 下記のelseの計算結果と合わせて_mm256_blendv_psで設定する
		//}
		//else {
		//	q = (float)(win / move_count);
		__m256 m256_win = _mm256_load_ps(win + i);
		__m256 m256_move_count = _mm256_cvtepi32_ps(m256i_move_count);
		__m256 m256_q_tmp = _mm256_div_ps(m256_win, m256_move_count);
		__m256 m256_q = _mm256_blendv_ps(m256_parent_q, m256_q_tmp, _mm256_castsi256_ps(mask));

		//	u = sqrt_sum / (1 + move_count);
		__m256 m256_move_count_plus1 = _mm256_add_ps(m256_move_count, m256_one);
		__m256 m256_u_tmp = _mm256_div_ps(m256_sqrt_sum, m256_move_count_plus1);
		__m256 m256_u = _mm256_blendv_ps(m256_init_u, m256_u_tmp, _mm256_castsi256_ps(mask));
		//}

		//const float rate = uct_child[i].nnrate;
		__m256 m256_rate = _mm256_load_ps(nnrate + i);

		//const float ucb_value = q + c * u * rate;
		__m256 m256_ucb_value = _mm256_mul_ps(m256_c, m256_u);
		m256_ucb_value = _mm256_mul_ps(m256_ucb_value, m256_rate);
		m256_ucb_value = _mm256_add_ps(m256_q, m256_ucb_value);

		// find max
		__m256 vcmp = _mm256_cmp_ps(m256_ucb_value, vmaxvalue, _CMP_GT_OS);
		vmaxvalue = _mm256_max_ps(m256_ucb_value, vmaxvalue);
		vmaxposition = _mm256_blendv_epi8(vmaxposition, vnowposition, _mm256_castps_si256(vcmp));
		vnowposition = _mm256_add_epi32(vnowposition, m256i_eight);
	}
	// find max
	const int* maxposition = (int*)&vmaxposition;
	__m256 vallmax = _mm256_max_ps(vmaxvalue, _mm256_shuffle_ps(vmaxvalue, vmaxvalue, 0xb1));
	vallmax = _mm256_max_ps(vallmax, _mm256_shuffle_ps(vallmax, vallmax, 0x4e));
	vallmax = _mm256_max_ps(vallmax, _mm256_permute2f128_ps(vallmax, vallmax, 0x01));
	__m256 vcmp = _mm256_cmp_ps(vallmax, vmaxvalue, _CMP_EQ_US);
	int mask = _mm256_movemask_ps(vcmp);
	max_child = maxposition[__builtin_ctz(mask)];

パッと見には何をやっているか分からない(;'∀')
8未満を処理するためにswichで分けている部分が美しくない。

最大値のインデックスを探す処理は、はじめ思いつかなかったが、Discordで教えを乞うたところ、やねうらお氏から参考情報を教えていただき、それを元に実装したコードを、@wain_CGP氏に添削していただいた(最終的にはもらったコードほぼそのまま)。

測定

上記のAVX化した処理を探索部に組み込んで、どれくら速くなるか測定を行った。
V100 8枚で、floodgateからサンプリングした100局面(1局面は詰みなので除外)で測定した。
DeepLearningShogi/benchmark.py at master · TadaoYamaoka/DeepLearningShogi · GitHub

測定結果
AVXなし AVXあり
平均 229621 239431 1.04
中央値 235244 242866 1.03
最大 264526 392199 1.48
最小 148579 152466 1.03

平均で、4%程NPSが上昇した。
最大では、1.48倍の局面がある。
探索する局面の子ノードの数によって効果が変わってくる。

コード

探索部に組み込んだ処理はこちら。
DeepLearningShogi/UctSearch.cpp at 63d17b043c4ca0a37d15ea4e417a63cdffb29cc6 · TadaoYamaoka/DeepLearningShogi · GitHub
詰みのAND-OR木探索も含んでいるので、さらに美しくなくなっている。

まとめ

Ceresにインスパイアされてノード選択処理のAVX化を行った。
結果、平均で4%程高速化することができた。

AVX化を行ってもCeresのNPSには遠く及んでいない。
Ceresは、AVX化よりも衝突を回避した並列アルゴリズムの方がNPSへの寄与が大きいと思われる。
そのうち、Ceresの並列アルゴリズムをdlshogiにも導入したいと考えている。

2021/4/4 追記

AVX化の前後で強さを確認を測定した。
持ち時間3分、1手1秒加算

   # PLAYER          :  RATING  ERROR  POINTS  PLAYED   (%)  CFS(%)    W    D    L  D(%)
   1 dlshogi_avx2    :     0.2    9.0   500.5    1000    50      52  370  261  369    26
   2 dlshogi         :    -0.2    9.0   499.5    1000    50     ---  369  261  370    26

AVX化しても強くなっていない。

将棋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.7.1 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)