TadaoYamaokaの開発日記

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

.NET Coreでログ出力する方法

C# ログ出力」で検索するとlog4netの情報が見つかるが、.NET Coreのアプリの場合、設定ファイルのlog4net.configを読み込むことができなかった。
NLogを使うと、あっさり解決できたのでメモを残しておく。

内容はほぼチュートリアル通り。

NLogをパッケージに追加

> dotnet add package NLog

ログ出力コード例

using System;

namespace NLogTest
{
    class Program
    {
        private static readonly NLog.Logger Logger = NLog.LogManager.GetCurrentClassLogger();

        static void Main(string[] args)
        {
            Logger.Info("Hello world");
        }
    }
}

設定ファイル例

[NLog.config]
<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">

    <targets>
        <target name="logfile" xsi:type="File" fileName="file.txt" />
        <target name="logconsole" xsi:type="Console" />
    </targets>

    <rules>
        <logger name="*" minlevel="Info" writeTo="logconsole" />
        <logger name="*" minlevel="Debug" writeTo="logfile" />
    </rules>
</nlog>

NLog.configは、プロジェクトのルートに配置しても読み込まれない。
dotnet build(もしくはdotnet run)で作成される「bin/Debug/netcoreapp2.0」に配置する必要がある。
dotnet build -c Release(もしくはdotnet run -c Release)の場合は、「bin/Release/netcoreapp2.0」

プロジェクトの設定ファイル(.csproj)に以下のように記述することで自動でコピーされる。

<Project Sdk="Microsoft.NET.Sdk">
...
  <ItemGroup>
    <Content Include="NLog.config">
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </Content>  
  </ItemGroup>

</Project>

設定ファイルの代わりにプログラム中に設定を記述することもできる。

        static Program() {
            var config = new NLog.Config.LoggingConfiguration();

            // Targets where to log to: File and Console
            var logfile = new NLog.Targets.FileTarget("logfile") { FileName = "file.txt" };
            var logconsole = new NLog.Targets.ConsoleTarget("logconsole");
                        
            // Rules for mapping loggers to targets            
            config.AddRule(NLog.LogLevel.Info, NLog.LogLevel.Fatal, logconsole);
            config.AddRule(NLog.LogLevel.Debug, NLog.LogLevel.Fatal, logfile);
                        
            // Apply config           
            NLog.LogManager.Configuration = config;
        }

実行結果

以下のようにログが出力される。

2019-08-20 22:40:38.1268|INFO|NLogTest.Program|Hello world

出力先がファイルで、相対パスで指定している場合は、.dllの場所(「bin/Debug/netcoreapp2.0」もしくは「bin/Release/netcoreapp2.0」)からの相対パスになる。


なお、NLogには、Microsoft Extensions Logging (MEL)という.NET Core標準の仕組みを使う方法もあるようだが、処理の記述が増えるため特に必要がなければ上記の方法の方が簡単にログ出力できる。
Getting started with .NET Core 2 Console application · NLog/NLog Wiki · GitHub

Prioritized Experience Replayのsum-treeの実装

つくりながら学ぶ!深層強化学習のPrioritized Experience Replayの実装は、説明をシンプルにするためReplay Memoryを線形で探索する実装が紹介されていた。
つまり、各transitionのTD誤差を優先度として、0からReplay Memoryの優先度の合計の間で、ランダムに数値を選び、Replay Memoryの格納順に優先度を足していき合計がランダムに選んだ数値超えた要素をサンプリングするという方法だ。

一方、元の論文では、Replay Memoryのサイズが大きい場合、線形探索は速度が問題になるため、sum-treeで実装することで計算量O(log N)にできることが説明されている。
論文には、sum-treeの具体的な説明がなかったので調べてみた。

sum-tree

sum-treeについては、以下のサイトの説明がわかりやすい。
https://jaromiru.com/2016/11/07/lets-make-a-dqn-double-learning-and-prioritized-experience-replay/
f:id:TadaoYamaoka:20190818154503p:plain

2分木の一番下段が、Replay Memoryに格納されたtransitionの優先度にあたり、親ノードが子ノードの合計値になっている。
このように2分木を構築することで、ランダムに選んだ数値から2分木を辿ることで、O(log N)でサンプリングができる。
sum-treeのPythonでの実装もGitHubで公開されている。

上記サイトで公開されていたPythonのコードを使って、実際に優先度に応じた確率でサンプリングされるか確認してみた。

sum-treeによるサンプリングの実験

まずは、サイズを指定してReplay Memoryを作成する。ここでは、サイズ10とした。

from SumTree import SumTree

replay_memory = SumTree(10)


作成後は、要素が追加されていないので、合計は0になる。

replay_memory.total()
Out: 0.0


適当な優先度を付けた10個の要素を追加する。関連付けるデータも格納できるが、ここではNoneを格納する。

P = [6, 48, 31, 26, 49, 43, 93, 74, 79, 13]

for p in P:
    replay_memory.add(p, None)


合計を確認する。

print(sum(P), replay_memory.total())
462 462.0

追加した値の合計と、replay_memoryのtotal()の値が一致している。


1万回サンプリングを行い、各要素が選択された割合を確認する。

from collections import defaultdict
p_sum = defaultdict(int)

for i in range(10000):
    s = random.randint(0, replay_memory.total() - 1)
    _, p, _ = replay_memory.get(s)
    p_sum[int(p)] += 1

for p in P:
    print("{}, {}, {}".format(p, p / sum(P), p_sum[p] / 10000))
6, 0.012987012987012988, 0.0128
48, 0.1038961038961039, 0.0987
31, 0.0670995670995671, 0.0648
26, 0.05627705627705628, 0.0591
49, 0.10606060606060606, 0.1061
43, 0.09307359307359307, 0.0878
93, 0.2012987012987013, 0.2075
74, 0.16017316017316016, 0.1622
79, 0.170995670995671, 0.174
13, 0.02813852813852814, 0.027

優先度に応じた確率(2列目)と、サンプリングされた割合(3列目)がほぼ等しくなっていることが確認できる。

Pythonで将棋の盤面をSVGで表示する

以前に作成したPython向けの高速な将棋ライブラリ「cshogi」に、盤面をSVGで表示する機能を追加した。

Jupyter Notebookで実行すると、以下のように盤面が画像で表示されるので視認しやすくなる。

from cshogi import *
board = Board()
board

987654321

直前の指し手も表示できる。

move = board.push_usi(b'7g7f')
board.to_svg(move)

987654321

持ち駒がある場合は以下のように表示される。

board.set_sfen(b'8l/1+B+P1G4/1K+B1+P1+Pp1/4Ppp2/3s1n2p/l2g2ks1/1P4+n1P/2r6/+p6+s1 b R2GSNL4Pnl3p 187')
board

987654321

Jupyter Nodebook

Google Colabで使い方がわかるJupyter Nodebookを公開したので参考にして欲しい。
colab.research.google.com

余談

先日将棋AIで学ぶディープラーニングのJupyter Notebookを公開したが、対局するには手順が多いため、Google Colabで完結して対局できるようにしたいと思っている。
そのための準備として、盤面の表示機能を追加した。
今後、(別ライブラリにするかもしれないが)usiエンジン同士の対局機能も追加する予定。

Windows上でPyTorchを使って強化学習を試す

以前はOpenAI GymはWindowsに対応してなかったが、以下のようにpipからインストールするだけで動くようになっている。
atariも問題なく動く。

pip install gym
pip install gym[atari]

ただし、env.render()で描画するとウィンドウの位置変更などができないという問題があった。これは後で書く別の方法で描画すれば回避できる。

とりあえず既にある強化学習の実装を試そうとして、ChainerRLを試してみたがマルチプロセス関係がWindowsでは動かなかったため、PyTorchでつくりながら学ぶ!深層強化学習で引用されていた以下のGitHubのコードを試してみた。
https://github.com/ikostrikov/pytorch-a2c-ppo-acktr-gail

インストール

GitHubのREADMEにある通り、baselinesとrequirements.txtから依存ライブラリのインストールが必要になる。

git clone https://github.com/ikostrikov/pytorch-a2c-ppo-acktr-gail.git

# Baselines for Atari preprocessing
git clone https://github.com/openai/baselines.git
cd baselines
pip install -e .

# Other requirements
cd ../pytorch-a2c-ppo-acktr-gail
pip install -r requirements.txt

実行

--num-processesを1にすればWindowsでもそのまま実行できる。

例)

python main.py --env-name "BreakoutNoFrameskip-v4" --num-processes 1

マルチプロセスで動かすには、envs.pyの91行目を以下の通り修正する。
修正前:

envs = ShmemVecEnv(envs, context='fork')

修正後:

envs = ShmemVecEnv(envs, context='spawn')

これで、Windowsでもマルチプロセスで動かせる。

ゲーム画面を描画する

学習を実行すると、コンソールにログしか表示されない。
高速に強化学習するには問題ないが、はじめのうちは学習している過程をゲーム画面で眺めたい。

ゲーム画面を表示するには、main.pyに以下のコードを追加する。
ファイルの先頭:

import cv2

120行目(obs, reward, done, infos = envs.step(action)の後)に追記:

            imgs = envs.get_images()
            cv2.imshow('img', imgs[0])
            cv2.waitKey(1)

envsは、OpenAI Gymのenvのマルチプロセス用のラッパーになっている。
get_images()で、マルチプロセスで実行しているすべてのエージェントのゲーム画面が取得できる。
0番目のエージェントの画面のみをOpenCVのimshowで描画する。
waiKey(1)がないと、ウィンドウに描画が更新されないため、1ミリ秒だけキー入力待ちをする処理を入れている。

f:id:TadaoYamaoka:20190815160821p:plain

この方法を使用して前述したenv.render()の問題を回避できる。
env.render()の代わりにenv.render(mode='rgb_array')で画像を取得して描画すればよい。

2019/10/22 追記

mtplotlib.pyplotを使用してもリアルタイムに描画できる。
参考:Running Open AI Gym on Windows 10 – Simon on the Web

将棋AIの進捗 その33(末端ノードでの詰み探索)

dlshogiでは末端ノードで短手数(7手)の詰み探索を実施しているが、終盤ではゲーム木中に詰みの局面が多くなり、王手の合法手も多くなるため、探索速度が大幅に低下することがあった。
先日MCTSにAND/OR木を組み込む実装を行ったことで、MCTSで詰み探索を行うことができるようになったので、詰みの手数を短くしても良いのではないかと思って、5手に減らして強さを確認してみた。

以下のような終盤の局面で、7手の場合と5手の場合で速度を比較すると、
f:id:TadaoYamaoka:20190813232004p:plain
position sfen 7nl/5kP2/3p2g1p/2p1gp3/p6sP/s1BGpN3/4nPSp1/1+r4R2/L1+p3K1L w GSNLPb6p 122

7手詰め 2783 nps
5手詰め 11757 nps

となり、7手と5手で探索速度に大きな差があることがわかった。

次に、5手詰めにした場合に、技巧2と1手3秒、やねうら王互角局面集を使用して100局対局して勝率を測定した。
結果は以下の通り。

勝敗数 勝率 信頼区間(95%)
7手詰め 38勝58負9分 41% 52.0%~32.2%
5手詰め 43勝51負6分 45% 55.8%~36.0%

勝率が上がっているが、100回対局では有意とは言えない差のため、対局数を増やして確認する必要がある。
少なくとも、5手詰めにしても弱くはなっていないことが確かめられた。
これがAND/OR木の効果なのかは、AND/OR木を外して別途確認する予定。
また、3手詰めにするとどうなるかも確認したい。


探索の工夫で、技巧2(CPU 4コア)にGPU1枚(2080Ti)で40%以上勝てるようになった。
今学習しているSENetのモデルがうまく学習できればもう少しで(GPU1枚で)技巧2を超えられそうである。

2019/8/14追記

末端ノードでの詰み探索を3手にした場合、以下の通りとなった。

探索速度
3手詰め 18914 nps
技巧2に対する勝率
勝敗数 勝率 信頼区間(95%)
3手詰め 29勝65負6分 30% 40.8%~22.4%

3手にした場合は、探索速度は5手詰めより1.6倍となっているが、勝率は明らかに低くなり弱くなっている。
末端ノードでの詰み探索の深さによる局面評価の精度と、探索速度による精度をバランスさせる必要があることがわかった。
実験からは、5手詰めが最も強くなりそうである。

ただし、現在7手詰めで探索速度が低下するのは、CPUがボトルネックになっているため、GPUに対するCPUのコア数を増やすことで対処できると考えている。

迷路を方策勾配法で解く

最近買った「つくりながら学ぶ!深層強化学習」という強化学習の本で、迷路を方策勾配法で解くという内容が記載されていたが、数式展開がなく自分で式を導出するのに苦労したのでメモを残しておく。

この本の迷路の問題の内容は、Webにも掲載されている。
第5回 ⽅策勾配法で迷路を攻略|Tech Book Zone Manatee

表形式のパラメータの更新則として以下の式が記載されている。
 \displaystyle
\theta_{s,a_j}=\theta_{s,a_j}+\eta\cdot\Delta\theta_{s,a_j} \\
\Delta\theta_{s,a_i}=(N(s,a_j)-p(s,a_j)N(s,a))/T
なお、書籍とWeb記事では、2つ目の式の符合が誤っている。
引用されている論文と式が違うので、まずここで躓いたのだが、正誤表に符合の誤りについて記載されていた。

さて、問題は2つ目の式の導出である。

以下では、方策勾配定理から書籍の式を導出するまでをできるだけ省略しないで行う。
方策勾配定理の近似式については引用されている論文これからの強化学習を参照。

式の導出

出発点となる方策勾配定理は、以下の式で表される。
 \displaystyle
\Delta\theta_{s,a} \sim \frac{1}{N} \sum_{i=1}^N \frac{1}{T} \sum_{t=1}^T \frac{\partial}{\partial \theta_{s_t,a_t}} \log p_{\theta_{s_t,a_t}}(a_t^i|s_t^i)z_T^i

ここで、添え字iはエピソードの番号を表しているが、1エピソードごとに更新するため、この問題では添え字iのシグマは削除できる。
報酬z_T^iは、ゴールにたどり着いた場合に、たどった経路すべてに割引なしで報酬1を与えるため、この式で常に1となる。
また、状態s_tと行動a_tは、1エピソードで同じ状態を通ることがあるため、状態sと行動状態aにまとめて記述する。

よって、式は以下のように記述できる。
 \displaystyle
\Delta\theta_{s,a} \sim \frac{1}{T} \frac{\partial}{\partial \theta_{s,a}} \log p_{\theta_{s,a}}(a|s)

ここで、方策p_{\theta_{s,a}}は、この問題ではSoftmax方策を採用しているため、
 \displaystyle
p_{\theta_{s,a}}(s|a) = \frac{e^{\theta_{s,a}}}{\sum_j e^{\theta_{s,a_j}}}
で与えらえる。


ここから、対数の公式と微分の公式を使って式を展開する。
 \displaystyle
\begin{eqnarray}
\Delta\theta_{s,a} &\sim& \frac{1}{T} \frac{\partial}{\partial \theta_{s,a}} \log p_{\theta_{s,a}}(a|s) \\
&=& \frac{1}{T} \frac{\partial}{\partial \theta_{s,a}} \log \frac{e^{\theta_{s,a}}}{\sum_j e^{\theta_{s,a_j}}} \\
&=& \frac{1}{T} \frac{\partial}{\partial \theta_{s,a}} (\log e^{\theta_{s,a}} - \log \sum_j e^{\theta_{s,a_j}}) \\
&=& \frac{1}{T} \frac{\partial}{\partial \theta_{s,a}} (\theta_{s,a} - \log \sum_j e^{\theta_{s,a_j}}) \\
&=& \frac{1}{T} (N_1 - \frac{\frac{\partial}{\partial \theta_{s,a}} \sum_j e^{\theta_{s,a_j}}}{\sum_j e^{\theta_{s,a_j}}}) \\
&=& \frac{1}{T} (N_1 - N_0 \frac{e^{\theta_{s,a}}}{\sum_j e^{\theta_{s,a_j}}})
\end{eqnarray}

ここで、N_1は、当該エピソードで状態sで行動aをとった回数、N_0は、当該エピソードで状態sを通った回数を表す。

式の展開で4行目から5行目への展開は、微分の連鎖則を使用している。
つまり、
 \displaystyle
\begin{eqnarray}
\frac{\partial}{\partial x} \log f(x) &=& \frac{\partial \log f(x)}{\partial f(x)} \frac{\partial f(x)}{\partial x} \\
&=& \frac{1}{f(x)} \frac{\partial f(x)}{\partial x}
\end{eqnarray}
を利用した。

式の5行目から6行目で、
 \displaystyle
\frac{\partial}{\partial \theta_{s,a}} \theta_{s,a} = N_1
となっているが、これは、1つのエピソードで状態sで行動aをとることが複数あるため、偏微分の結果は1だが、N_1回足す必要があるためである。

また、式の6行目から7行目で、
 \displaystyle
\frac{\partial}{\partial \theta_{s,a}} \sum_j e^{\theta_{s,a_j}} = N_0 e^{\theta_{s,a}}
となっているが、これは、1つのエピソードで状態sを複数とることがあり、行動a以外を取った場合も\sum_jの中に含まれているため、N_0回足す必要があるためである。
(補足:指数の微分が指数になる部分の式の展開は省略している。)


さらに、式を展開すると、Softmaxの定義から、
 \displaystyle
\begin{eqnarray}
\Delta\theta_{s,a} &\sim& \frac{1}{T} \frac{\partial}{\partial \theta_{s,a}} \log p_{\theta_{s,a}}(a|s) \\
&=& \frac{1}{T} (N_1 - N_0 \frac{e^{\theta_{s,a}}}{\sum_j e^{\theta_{s,a_j}}}) \\
&=& \frac{1}{T} (N_1 - N_0 p_{\theta_{s,a}}(a|s))
\end{eqnarray}
となり、書籍の式が導出できた。

SocketでC#とPythonを連携する

先日、gRPCでC#Pythonを連携する方法について書いたが、Pythonで受信したProtobufのデータを処理すると実行速度に問題があることがわかった。
速度が必要なケースでは、Protobufの使用はあきらめた方がよさそうだ。

今回は、シンプルにSocket通信でデータを渡してPythonで処理する方法について記述する。
Socketでデータを送受信する場合、データの中身はただのバイナリになるため、構造化されたデータを送受信する場合は、データのシリアライズとデシリアライズの処理を記述する必要がある。
データが固定長であれば、処理しやすいが、可変長の場合は、送受信のデータにサイズを含めるなどの処理が必要になる。

C#側(クライアント)

サンプルコード:
SocketSample/Program.cs at master · TadaoYamaoka/SocketSample · GitHub

以下のようにしてSocketでサーバに接続する。

TcpClient tcp = new TcpClient("127.0.0.1", 50007);
NetworkStream ns = tcp.GetStream();

送信するデータは、BitConverter.GetBytesでbyte配列に変換して送信する。
通信回数を抑えるため、MemoryStreamに格納してまとめて送信する。

var ms = new MemoryStream();
int size = 4 + 4 + 4 * a.b.Length;
ms.Write(BitConverter.GetBytes(size), 0, 4);
// ...
ns.Write(ms.ToArray(), 0, 4 + size);

応答の受信は、Readで行う。byte配列で受け取るため、BitConverter.ToInt32などで数値など変換する。

byte[] int_bytes = new byte[4];
ns.Read(int_bytes, 0, 4);
size = BitConverter.ToInt32(int_bytes, 0);

最後に接続をクローズする。

ns.Close();
tcp.Close();

Python側(サーバー)

サンプルコード:
SocketSample/SocketServer.py at master · TadaoYamaoka/SocketSample · GitHub

以下のように接続待ち→接続を行う。

s.bind(('127.0.0.1', 50007))
s.listen(1)
conn, addr = s.accept()

受信は、recvを使用するとバイト列が返る。recv_intoを使うとバッファに直接書き込む。バッファにはnumpyのndarrayが使用できる。

size = int.from_bytes(conn.recv(4, socket.MSG_WAITALL), sys.byteorder)
# ...
data = np.empty(size, dtype=np.uint8)
conn.recv_into(data, size, socket.MSG_WAITALL)

応答の送信は、sendallで行う。numpyのndarrayは直接引数に渡せる。

res = np.array([1, 2, 3])
conn.sendall(len(res).to_bytes(4, sys.byteorder))
conn.sendall(res)

シリアライズとデータ加工をCythonで行う

バイト列をデシリアライズして、機械学習で使用するためにデータ加工を行うような場合は、実行速度をできるだけ高速化したい。
そのような場合、Cythonを使うことでデシリアライズとデータ加工をC++で記述できる。
サンプルコード:
SocketSample/Decoder.pyx at master · TadaoYamaoka/SocketSample · GitHub
SocketSample/Decoder_impl.cpp at master · TadaoYamaoka/SocketSample · GitHub
データの受け渡しをnumpyのndarrayにすることで、余分なメモリコピーを抑えることができる。