読むとGPAが上がるブログ(仮)

GPA芸人が気の赴くままに何かを書くブログ

数学を厭わない強化学習(その0:用語・文字定義)

\usepackage{amsmath,amssymb}
\usepackage{pifont}

\usepackage{bigints}
\usepackage{bm}
\usepackage{siunitx}

\newcommand{\hs}[1]{\hspace{#1zw}}
\newcommand{\vs}[1]{\vspace{#1zh}}
\newcommand{\hsh}{\hs{0.5}}
\newcommand{\vsh}{\vs{0.5}}

\newcommand{\eref}[1]{\text{式\eqref{eq:#1}}}

\newcommand{\Nset}{\mathbf{N}}
\newcommand{\Zset}{\mathbf{Z}}
\newcommand{\Qset}{\mathbf{Q}}
\newcommand{\Rset}{\mathbf{R}}
\newcommand{\Cset}{\mathbf{C}}

\DeclareMathOperator*{\argmax}{\mathrm{arg\,max}}
\DeclareMathOperator*{\argmin}{\mathrm{arg\,min}}

\mathchardef\ordinarycolon\mathcode`\:
\mathcode`\:=\string"8000
\begingroup \catcode`\:=\active
  \gdef:{\mathrel{\mathop\ordinarycolon}}
\endgroup

\newcommand{\cond}[2]{#1\,|\,#2}

\newcommand{\expct}{\mathbb{E}}
\newcommand{\expctpi}{\mathbb{E}_{\pi}}

\newcommand{\Scal}{\mathcal{S}}
\newcommand{\Scalp}{\mathcal{S}^+}
\newcommand{\Acal}{\mathcal{A}}
\newcommand{\Rcal}{\mathcal{R}}

\newcommand{\sums}{\sum_s}
\newcommand{\sumsp}{\sum_{s'}}
\newcommand{\suma}{\sum_a}
\newcommand{\sumap}{\sum_{a'}}
\newcommand{\sumr}{\sum_r}

\newcommand{\pias}{\pi(\cond{a}s)}
\newcommand{\pitheta}{\pi_{\bm\theta}}
\newcommand{\pithetaas}{\pi_{\bm\theta}(\cond{a}s)}
\newcommand{\Ppi}{P_{\pi}}
\newcommand{\Ppin}{P_{\pi}^{~n}}
\newcommand{\Pcond}[2]{P(\cond{#1}{#2})}
\newcommand{\Ppicond}[2]{\Ppi(\cond{#1}{#2})}
\newcommand{\Ppincond}[2]{\Ppin(\cond{#1}{#2})}
\newcommand{\Psrsa}{\Pcond{s', r}{s, a}}
\newcommand{\Pssa}{\Pcond{s'}{s, a}}
\newcommand{\Prsa}{\Pcond{r}{s, a}}
\newcommand{\Ppisrs}{\Ppicond{s', r}s}
\newcommand{\Ppiss}{\Ppicond{s'}s}
\newcommand{\Ppirs}{\Ppicond{r}s}
\newcommand{\Ppinss}{\Ppincond{s'}s}
\newcommand{\Dpigamma}{D_{\pi}^{\,\gamma}}

\newcommand{\follow}{\sim}
\newcommand{\followpis}{\follow\pi(\cond{\cdot}s)}
\newcommand{\followpisp}{\follow\pi(\cond{\cdot}s')}
\newcommand{\twofollowPsa}{\follow\Pcond{\cdot, \cdot}{s, a}}
\newcommand{\onefollowPsa}{\follow\Pcond{\cdot}{s, a}}
\newcommand{\twofollowPpis}{\follow\Ppicond{\cdot, \cdot}s}
\newcommand{\onefollowPpis}{\follow\Ppicond{\cdot}s}

\newcommand{\Rsa}{R(s, a)}
\newcommand{\Rpis}{R_{\pi}(s)}

\newcommand{\prd}[1]{\bar{#1}}
\newcommand{\prds}{\prd{s}}
\newcommand{\prda}{\prd{a}}

\newcommand{\Vpi}{V_{\pi}}
\newcommand{\Vpis}{\Vpi(s)}
\newcommand{\Vpisp}{\Vpi(s')}
\newcommand{\Qpi}{Q_{\pi}}
\newcommand{\Qpisa}{\Qpi(s, a)}
\newcommand{\Qpispap}{\Qpi(s', a')}

\newcommand{\Vpihat}{\hat{V}_{\pi}}
\newcommand{\Vpihats}{\hat{V}_{\pi}(s)}
\newcommand{\Qpihat}{\hat{Q}_{\pi}}
\newcommand{\Qpihatsa}{\hat{Q}_{\pi}(s, a)}

新シリーズです。

数学から逃げるな

ということで、強化学習の数学的なところを適当にかいつまんで、できる限り明瞭に説明・証明していくシリーズになる予定です。

シリーズ最初の今回は、このシリーズで使っていく文字の定義とかを紹介していきます。 ものによってはここで定義とか定理とかイメージとかまで説明してしまいます。

なお、内容は随時追加・修正される可能性があります。

また、スマートフォンから見ると表示が崩れる現象が確認されているので、PCで見るか、スマホで見る場合はPCモードにして見るか気合で見るかしてください。

文字などの紹介・定義

数学的な操作・強化学習の基本的な文字

  • $\mathrm{Pr}\{X=x\}:確率変数 $X が値 $x を取る確率。
  • $\expct[X]:確率変数 $X の期待値。
    • $\expct[X]:=\sum_{x\in\mathcal{X}}\mathrm{Pr}\{X=x\}x
      • $\mathcal{X} は $x が取り得る値全体の有限集合。
    • $\expctpi[X]:方策 $\pi に従って行動した場合の、確率変数 $X の期待値(詳しくは後述)。
  • $\bm\nabla_{\bm\theta}:ベクトル $\bm\theta による偏微分の、偏微分作用素ベクトル
  • $\gamma:時間割引率。
    • 通常、定数のパラメータ。
    • $\gamma\in[0, 1]
  • $\Scal:非終端状態集合(取り得る非終端状態全体の有限集合)。
  • $\Scalp:状態集合(取り得る状態全体の有限集合)。
  • $\Acal:行動集合(取り得る行動全体の有限集合)。
    • 行動集合が状態に依存する場合は、$\Acal(s) で状態 $s における行動集合を表す。
    • $s が終端状態である場合、$\Acal(s):=\emptyset とする。
  • $\Rcal:報酬集合(取り得る報酬全体の有限集合)。
    • $\Rcal\subset\Rset
  • $s:状態。
    • $s\in\Scalp
  • $a:行動。
    • $a\in\Acal
  • $r:報酬。
    • $r\in\Rcal

強化学習でよく出てくる演算のエイリアス

  • $\sums f(s):=\sum_{s\in\Scal}f(s)
    • $\suma f(a) や $\sumr f(r) についても同様。
  • $\sum_{s, a} f(s, a):=\sum_{s\in\Scal}\sum_{a\in\Acal(s)}f(s, a)
    • $\sum_{a, r}f(a, r) や $\sum_{s, r}f(s, r) についても同様。
  • 同様に、走る範囲が書いていない総和計算については、$\Scal や $\Acal(s)、$\Rcal のうち適当なものを走る範囲として選択する。

実際に行動する際に出てくる文字

  • $t:時刻。
    • $t\in\Nset\cup\{\infty\}
  • $T:終端状態に到達した時刻。
    • $T\in\Nset\cup\{\infty\}
      • $T=\infty の場合は、終端状態がない(永遠に行動し続けられる)ことを表す。
      • $T=\infty となり得る場合は、時間割引率 $\gamma\in[0, 1) とする。
  • $s_t:時刻 $t における状態。
    • $s_t\in\Scalp
  • $a_t:時刻 $t において取った行動。
    • $a_t\in\Acal
  • $r_t:時刻 $t-1 において取った行動により得られた報酬。
    • $r_t\in\Rcal
  • $C_t:時刻 $t から終端状態に到達するまでに得られた報酬たちの割引報酬和。
    • $C_t:=\sum_{t'=t+1}^T\gamma^{t'-t-1}r_{t'}
    • ただし、時刻 $t における状態が終端状態であった場合には、$C_t:=0 とする。

確率・確率分布

  • $\pi:方策(取る行動を決定する規則)。
    • $\pithetaas:状態 $s において行動 $a を取る確率。計算に用いるパラメータとして $\bm\theta を持つ。
      • $s\in\Scal、$a\in\Acal(s)
    • $\pias:$\pithetaas の省略形。文字が多いと見にくくなるので、普通は $\bm\theta は省略する。
  • $\Psrsa:状態 $s において行動 $a を取った場合に、次の状態が $s' であり、かつ得られる報酬が $r である確率。
    • $\Psrsa:=\mathrm{Pr}\{\cond{s_{t+1}=s', r_{t+1}=r}{s_t=s, a_t=a}\}
    • $s\in\Scal、$s'\in\Scalp、$a\in\Acal、$r\in\Rcal
  • $\Pssa:状態 $s において行動 $a を取った場合に、次の状態が $s' である確率。
    • $\Pssa:=\mathrm{Pr}\{\cond{s_{t+1}=s'}{s_t=s, a_t=a}\}
    • $s\in\Scal、$s'\in\Scalp、$a\in\Acal
    • 任意の $s\in\Scal、任意の $s'\in\Scalp、任意の $a\in\Acal に対し、$\sumr\Psrsa=\Pssa
    • $\Prsa も同様に定義する。もちろん成り立つ性質も同様に成り立つ。
  • $\Ppisrs:状態 $s において方策 $\pi に従って行動した場合に、次の状態が $s' であり、かつ得られる報酬が $r である確率。
    • $\Ppisrs:=\suma\pias\Psrsa
    • $s\in\Scal、$s'\in\Scalp、$r\in\Rcal
  • $\Ppiss:状態 $s において方策 $\pi に従って行動した場合に、次の状態が $s' である確率。
    • $\Ppiss:=\suma\pias\Pssa
    • $s\in\Scal、$s'\in\Scalp
    • 任意の $s\in\Scal、任意の$s'\in\Scalp に対し、$\sumr\Ppisrs=\Ppiss
    • $\Ppirs も同様に定義する。もちろん成り立つ性質も同様に成り立つ。
  • $\Ppinss:状態 $s において、方策 $\pi に従って $n 回行動した後の状態が $s' である確率。
    • $\Ppinss:=\sum_{s_1, s_2, \ldots, s_{n-1}}\prod_{t=0}^{n-1}\Ppicond{s_{t+1}}{s_t}
      • 但し、$s_0:=s、$s_n:=s' とする。
    • $s\in\Scal、$s'\in\Scalp、$n\in\Nset
    • $s=s' ならば $P_{\pi}^{~0}(\cond{s'}s):=1、$s\neq s' ならば $P_{\pi}^{~0}(\cond{s'}s):=0 とする。
  • $\iota(s):状態 $s が開始状態となる確率。
    • $s\in\Scal
  • $\Dpigamma(s):方策 $\pi に従って開始状態から終端状態になるまで行動する場合の、状態 $s の考慮度合い。
    • 「方策 $\pi に従って開始状態から終端状態になるまで行動する場合に、途中で状態が $s となっている確率」に対し、時間割引率 $\gamma を用いた時間の重みをつけたもの。
    • $\Dpigamma(s):=\sum_{s_0} \iota(s_0)\sum_{n=0}^{\infty}\gamma^n\Ppincond{s}{s_0}
    • $s\in\Scalp
    • 確率分布に近い存在であり、同じような意味合いで用いられるが、厳密には確率分布ではない($\gamma=1 なら確率分布)。

確率分布からのサンプリング

  • 確率を表す文字の中に「$\cdot」が含まれている場合、そこに入っていた文字をサンプリングすることを表す。確率は、「$\cdot」が含まれていない場合の確率とする。以下に例を示す。
    • $a\followpis:「任意の $a\in\Acal(s) に対して、$a に対応する($a が選ばれる)確率が $\pias であるような確率分布」から $a をサンプリングすることを表す。
    • $s', r\twofollowPsa:「任意の $s'\in\Scalp、任意の $r\in\Rcal に対して、$s', r が(同時に)選ばれる確率が $\Psrsa であるような確率分布」から $s', r をサンプリングすることを表す。
    • $r\onefollowPpis:「任意の $r\in\Rcal に対して、$r が選ばれる確率が $\Ppirs であるような確率分布」から $r をサンプリングすることを表す。

期待値

  • $\Rsa:状態 $s において行動 $a を取った際に得られる報酬の期待値。
    • $\Rsa:=\expct[\cond{r_{t+1}}{s_t=s, a_t=a}]
    • $s\in\Scal、$a\in\Acal(s)
  • $\Rpis:状態 $s において、方策 $\pi に従って行動した際に得られる報酬の期待値。
    • $\Rpis:=\expctpi[\cond{r_{t+1}}{s_t=s}]
    • $s\in\Scal
  • $\Vpis:状態価値(状態 $s の価値)。
    • $s\in\Scalp
    • 状態 $s から方策 $\pi に従って終端状態になるまで行動を続けた場合に得られる割引報酬和の期待値と定義する。
      • $\Vpis:=\expctpi[\cond{C_t}{s_t=s}]
      • $s が終端状態である場合には、$\Vpis:=0 とする。
  • $\Qpisa:行動価値(状態 $s において行動 $a を取ることの価値)。
    • $s\in\Scalp、$a\in\Acal(s)
    • 状態 $s において行動 $a を取り、その後は方策 $\pi に従って終端状態になるまで行動を続けた場合に得られる割引報酬和の期待値と定義する。
      • $\Qpisa:=\expctpi[\cond{C_t}{s_t=s, a_t=a}]
      • $s が終端状態である場合には、$\Qpisa:=0 とする。

添字などの注意点

  • 状態 $s などで、添字等がついてないものは現在の状態を、プライムがついた $s' などは未来の状態を、上付き線がついた $\prds などは過去の状態を表すことが多い。
    • ただし、総和を取る際の走る文字に用いられている場合はこの限りではない。単に文字の重複を避けるためにプライムを付けるなどしている。
  • 方策 $\pias 等に出てくる縦線は、条件付き確率を表す(この場合は、$s が「条件」である)。
  • このシリーズでは、$\Nset は非負整数全体の集合とする。
  • 状態 $s_t において行動 $a_t を取ることで、状態は $s_{t+1} になり、同時に報酬 $r_{t+1} を受け取った、というような時刻の付け方である。報酬の添字が間違えやすいので注意。
  • なにかしらの文字にサーカムフレックスがついている場合(例:$\Vpihats)、それは(ニューラルネットワーク等による)近似値であることを表す。

$\expctpi について

前述したように、$\expctpi は「方策 $\pi に従って行動した際の期待値」を表す。 これをより明確に定義する。

基本的に、$\expctpi は $\expctpi[\cond{A}{B}] の形で現れる。 このとき、$A には、計算するために行動を要するものが入る(例:割引報酬和 $C_t や報酬 $r_t など)。 なお、割引報酬和が例に挙げられていることから分かるように、この行動は1回とは限らない。 計算できるようになるまで(例えば割引報酬和なら、状態が終端状態に到達するまで)行動することを考える。 この全ての行動を方策 $\pi に従って行う、ということである。

注意点

  • $B の部分に行動を指定する条件が入っていた場合は、当然それが優先される。
  • 上で説明したことを視点を変えて表現すると、「条件である $B の部分に『方策 $\pi に従って行動する』という条件が(暗に)課されている」となる。従って、この条件(方策 $\pi に従う)の一部を明示しても支障はない。式変形の都合上、これを行うことがある。

まとめ

今回は文字定義とかなので特に面白いところもないですね。 次回から中身のある話になると思います。

デレ7th大阪公演Day1参加記

2020/2/15(土)、デレマスの大阪公演に行ってきました。 せっかくなので参加記的な何かを簡単に書きます。

背景

おれはデレステを始めたのが去年の8月中旬、ミリシタを始めたのが去年の大晦日ということで、順調にm@s沼に足を突っ込んでいってるところです。 つい数日前にも人生初の天井を経験し、また1つ実績解除したところです。

この沼に入った以上ライブに行くのはもはや宿命であって、某オタクの誘いもあってこの度大阪公演に行くことになったわけですね。 m@sライブどころかライブに行くというのが人生初なので、何も分からないまま本番を迎えました(この文章は行きの電車内で書いています)。 このオタクには準備等々大変お世話になりました。ありがとうございます。

準備

準備については前々からオタクに聞いていたんですが、普通に授業とかテストとかで忙しく、先延ばしにし続けました。 その結果、準備を始めるのが公演前日のPMになりました。

まあとりあえず今回はどうにかなりましたが、次回からは気をつけます。 名刺も準備できなかったし。

特に公演グッズがかなり早いタイミングで用意されているのは驚きましたね。 ミリオン7thのグッズがもう申し込めるとは……。 こちらに参加するときは名刺・グッズ等の準備を万端にしていきます。

当日(ライブ前)

特に変わったこともなく大阪観光してました。 たこ焼きとか食べたりオタクがガチャ回しまくってるのを見てたりしてました。

京セラドームの最寄り駅降りてからは面白かったですね。 店頭で待ち受けプリンスが流れて、隣のオタクが挙動不審になったり後ろのオタクが歌い始めたりしてましたね。 円盤回してる人たちもいて、この時点で既に異世界に来てしまったことを痛感しました。

ドーム前には、もはや現代では絶滅したと思われていた所謂オタクという見た目のオタク、推しをアピールする服装に身を纏ったオタクがいて、現実で見るのが初めてなのでおお〜〜〜ってなりましたね。

面白かったのは、そういうオタクの推しが全くといってよいほど被ってなかったことですね。 コンテンツの多様性を感じました。

某研の人たちと一緒に待機してたのですが、コミュ障発揮して全然喋れませんでした。 次回お会いしたらもうちょい努力します。

当日(ライブ直前)

席が最上段も最上段、最後尾でした(バルコニー除く)。 これが一般……。

後ろが誰もいなかったのでそこに荷物置けたのは爆アドですね。 景色も壮観でした。 入場してからは連番のオタクに光る棒の使い方とか教わってました。

当日(ライブ中)

ガールズが入場してきたわけですが、席が席なので動きすらほぼ見えませんでした。 なのでほとんどモニター見てましたね。 オペラグラス使えばそこそこ見えないこともないんですけど、視野も狭いし動けないしで結構使い勝手が厳しいところがありました。 メガネみたいな感じでかけられるやつなら割とアリかな~と思いました。

肝心のライブ本体ですが、めっっっちゃ良かったですね……。 棒の振り方とかマジで適当やってたんですけど、それでも楽しかったです。 予習リストの精度がすごい高くてびっくりしました。

あと、生バンド、すごい。 いや他のライブ行ったことないので比較できないんですけど、やはりすごいと思いました。 音の質が良いです。

ただ最後尾なので後ろから声が聞こえないのがある意味残念でしたね~。 やはり雰囲気酔いじゃないですけど、そういう効果あると思うんで、四方八方から声が聞こえてきた方がいいなあとも思いました。

もともとCuを推してるんですけど、Coにかなり心を動かされるライブでした(ピュアピュアで影響を受けやすいので予習段階からちょっと動いてた)。 PANDEMIC ALONEあたりからが個人的にヤバかったですね、一番強く泣いたのはそこあたりだと思います。 トワスカ来てたらマジで号泣でしたね。 あ、あと勿論挨拶の例の場面でも泣きました。 こっちもかなりヤバかったです。

みたいな話を連番オタクにしたら、スロースターターだみたいなこと言われました。 確かにその自覚はありますね。 ただまあ、席の関係と、あとは初ライブでなかなか没入しきるのに時間がかかったのもあると思います。 そこらへんは今後の課題ですね。

当日(ライブ終了後)

セトリ確認しながら感情を呼び起こしつつ、銭湯に歩いて行きました。 銭湯の中で執拗に下の方を触ってくるおじちゃんに驚いたりはしましたが、まあそれはいいとして。

その後、某研の方たちに混ざって打ち上げしてました。 店内BGMがずっとデレマス曲だし、隣のグループもm@sオタクだし、もう騒ぎまくってましたね。 普通の店だったら間違いなく出禁ですよあれは。

某オタクによるオタク語りも聞いたんですが、すごい深く考えてるなあと思ってはっとさせられました。 今のおれは、アイドルに関してここまで語ることはできないな、と思いました。 自分のアイドルへの情熱の低さを痛感させられました。

その後、サンライズに乗って帰宅です。 正直今となっては両日行きたかったのですが、まあ初ライブということで、チュートリアルで1日参戦ですね。 次回(Q@MP)は両日行きます。

まとめ

アイドルに対してもっと真剣になって、次回のライブ、Q@MP FLYERに臨みたいと思います。 両日行きます。

オススメSS紹介(その1:咲SS編)

最近はあまり読めてませんが、昔は相当な数のSSを読んでいたので、せっかくなので良さげな作品を紹介していく予定のシリーズにします。 咲SSは一番読んでる数が多いので、割といいのが紹介できると思います。

とりあえずSSフォルダを漁って見つけたいい感じのやつを、適当に9作品ほど紹介します。 麻雀なので9ということで。

和「咲ちゃんに萌える青春ADV『咲-Saki-』です」

咲ちゃんそっくりな子が出てくるゲーム(作成 : モンブチ(いつもの))に、あらゆる人がハマっていくお話です。 咲ちゃん天使だからね、仕方ないね。 咲ちゃんは言わずもがなとして、それにハマっているみんなも可愛いです。

エイスリン 「ナマエ……ゲンツキ・ホー?」 和 「ホーwwww」

さきほどとはうってかわって、タイトルから分かる圧倒的コメディ作品の傑作。 咲SSerならほとんどの人が知ってるんじゃないかと勝手に思ってます。 ブラックエイスリンも好きですね、いやエイスリン過激派には怒られそうなんですけどそこは許してください。 内容が意味不明なのにすらすら読めてしまう、謎の魅力があります。

洋榎「大阪デートや!」 初美「よろしくですよー」

なかなか見ないカップリングのデート作品です。 ネキが出てくると安心感がありますよね。 さすがは名門姫松のエース。

ただ、この作品をわざわざ紹介しているだけあり、他の作品とは一線を画す、間違いなく咲SSの名作の1つなのです。 確か当時の咲SS人気投票みたいなやつで1位になってたと思います。 詳しくは、実際に読んで確かめてください。

愛宕洋榎「はいらっしゃいらっしゃい、美味しいたこ焼きやでー」

せっかくなので姫松続きでこちらを。 やっぱ姫松なんですよね。 色んな人が出てきて、ゆるーい感じの絡みを見ていられるという感じですね。 ある種日常系アニメに近いものがあるかもしれません。

花田煌「部活のみんなが何を言ってるかわからない……」

博多弁講座SSです。 男性諸君は博多弁大好きだと思うので、その意味で必修SSですね。 もちろんおれも大好きだし新道寺も大好きです。 制服のデザインが素晴らしいですよね、さすが制服人気ランキング第1位だけある(どこかで見た)。

白望 「二者択一……?」

こちらは宮守女子のイケメン、シロが主人公のミステリー?みたいな作品です。 これも名作として挙げられることの多い作品だと思います。 ミステリーなのでちょっと内容は紹介できないんですよね、なので是非是非読んでください。

野依「野依日和!」プンスコ

プロ雀士成分が足りなかったので1つ入れておきます。 俺はのよりんとわっかんね~の人が特に大好きなわけですが(隙自語)、のよりんが可愛いので素晴らしいと思います(小並感)。

まあタイトルから分かると思いますが、咲日和のオマージュです。 ほぼ同じ感じです。


ここから2作品はシリーズものです。

咲「ノドカの牌?」

タイトルで気付く人もいると思いますが、ヒカルの碁のオマージュ?らしいです(読んだことないので確証はない)。 どちらかと言うと咲よりも和が主人公ってことになるような気もします。

普通の人だった和が、平安時代の人の超強雀士咲ちゃんと出会い、麻雀の道に入っていくお話ですね。 もちろん咲ちゃんも生い立ちからして普通ではないので、そこらへんのお話もメインになってきます。 なかなか読み応えのある作品です。

怜「うちがエロゲしてる所を竜華に見られてしもた……」

最後はタイトルでいきなり一部の人を跳ね飛ばすこちらのシリーズです。 これは咲SS界隈では相当有名なシリーズだと思います。

まあ内容はタイトル読めば分かりますね。 さらに4作目のタイトルも見れば、竜華もハマるんかい!ってところまで分かりますね。 6作品でとりあえず完結するわけですけど、俺は個人的にすごい好きな展開というか、とにかく好きです(語彙力)。

おわりに

というわけで、9作品(一部シリーズ)を紹介しました。 なるべく色々なジャンルの作品を紹介しようと努力しましたが、選んでいるのは俺なのでバイアスはかかってると思います。

あとまとめてて気付いたんですけど、昔の作品ばっかりになってしまいました。 大体2012年とか2013年とかです。 まあ一番SSを読んでいたのがその頃なので、どうしてもそうなってしまいますね~。

他にも紹介したい作品はありましたが、あまり多くなってもあれなので、またの機会にということで。

強化学習のアルゴリズム紹介(その1:DQN)

\usepackage{amsmath,amssymb}
\usepackage{pifont}

\usepackage{bigints}
\usepackage{bm}
\usepackage{siunitx}

\newcommand{\hs}[1]{\hspace{#1zw}}
\newcommand{\vs}[1]{\vspace{#1zh}}
\newcommand{\hsh}{\hs{0.5}}
\newcommand{\vsh}{\vs{0.5}}

\newcommand{\eref}[1]{\text{式\eqref{eq:#1}}}

\newcommand{\Nset}{\mathbf{N}}
\newcommand{\Zset}{\mathbf{Z}}
\newcommand{\Qset}{\mathbf{Q}}
\newcommand{\Rset}{\mathbf{R}}
\newcommand{\Cset}{\mathbf{C}}

\DeclareMathOperator*{\argmax}{\mathrm{arg\,max}}
\DeclareMathOperator*{\argmin}{\mathrm{arg\,min}}

\mathchardef\ordinarycolon\mathcode`\:
\mathcode`\:=\string"8000
\begingroup \catcode`\:=\active
  \gdef:{\mathrel{\mathop\ordinarycolon}}
\endgroup

はじめに

こんにちは。 まさかの新シリーズです。 このシリーズでは、強化学習の基礎シリーズで説明をふっとばした、「学習本体」に焦点をあてていきます。

具体的には、各アルゴリズムの実装をしながら、そのイメージを説明するところを目的とします。 なので数式とかはなるべく出さないようにするつもりですが、数式があった方がイメージしやすいところは使います。

扱うアルゴリズムは、

  • DQN
  • Rainbow
  • REINFORCE
  • PPO
  • A2C

です。 今回は、最初のDQNを扱います。

あと、今回もコードはGitHubに上げてあります。

DQNの概要

DQNドキュンじゃないです。 Deep Q-Networkを指します。 Q学習に深層学習(Deep)を取り入れたものとなっています。

Q学習の概要

Q学習では、強化学習の基礎シリーズで説明したのとは異なるアプローチを考えます。 強化学習の基礎シリーズでは、「エージェントは入力を受け取って(望ましい)行動を出力する」と説明しました。 従って、強化学習の実装例とその解説で紹介したように、行動そのもの、もしくはその確率を直接出力するのが自然です。

しかし、Q学習は少し異なるアプローチを用います。 Q学習のエージェントは、「(入力を受け取り、そこでの)行動の価値」を出力します。 そして、エージェントは行動の価値が高い行動を取る、という風にするわけです。

この行動の価値を行動価値といい(そのまま)、数式中では $Q(s, a) として表すので、Q学習といいます。 なお、$s は状態(=入力)、$a は行動を表します。 ちなみに、強化学習ではよく入力を状態と言います。

そして、入力から出力を求める演算部分に(層が多めの)ニューラルネットワークを使うと、DQNとなります。

DQNの学習方法

今回は、状態から行動価値を求める演算を自動調整することになります。 で、どう調整するのかと言うと、

Q(s_t, a_t)=r_{t+1}+\gamma\max_{a}Q(s_{t+1}, a)

となる方向に調整します。 ただし、状態 $s_t において行動 $a_t をとることで、報酬 $r_{t+1} を得て、状態 $s_{t+1} になったものとします。

要は、行動価値を再帰的に作りたいわけです。 $\gamma という謎の文字がありますが、とりあえず無視すると、

「ある行動の価値は、その行動で貰えた報酬と、『その行動で遷移した先の状態における行動価値の最大値』との和」

であるということになります。 これを解釈すると、行動価値とは、「(行動価値が最大の行動を取ると仮定して)その行動をしてから貰える報酬の和」ということです。 これが無限級数になってしまうので、行動価値を用いて漸化式で表現している、という感じです。

そして、この $\gamma は、時間割引率といいます。 未来の価値(報酬)を小さめにするための係数で、0と1の間の値をとります。 これはまあ数学的な事情によるものですが、すぐ貰える報酬(=行動価値)の方が未来に貰えるそれよりも価値が高い、という風に考えてもいいと思います。 クレジットカードも個人情報を支払う対価として未来に(価値の低くなった)現金を支払っているわけですが、これと同じことです。

また、「〜〜となる方向に更新」という表現がありますが、これは本当にイコールになるようにすると今までの学習が無駄になってしまうので、イコールになるように(差を小さくするように)少しだけ更新する、という方法を通常取るからです。 これは機械学習フレームワークで機能が提供されているし、今回の本題でもないので、詳しい説明は割愛します。

DQNの実装

前回の記事では、方策勾配法というやつを紹介しました。 これと比べて、出力するものが行動(の確率)から行動の価値に変わるだけなので、根本的なところは変わりません。 どうせどちらも数値なので、見た目ではほぼ同じです。

方策勾配法との主要な違いは、

  • 基本的に価値が大きい行動をとる(greedy方策という)。
    • ただし、その価値はあくまで推定値であり嘘かもしれないので、たまに(確率 $\varepsilon で)ランダムに行動してみる(これを $\varepsilon-greedy 方策という)。
      • $\max_aQ(s_{t+1}, a) の部分が推定値になっている

くらいです。

なお、$\varepsilon-greedy方策に代わるものとして、ボルツマン方策とかがありますが、説明は割愛します。

学習の流れ

おおまかには以下の流れで学習を進めます。

  1. 環境をresetし、観測結果を受け取る。

  2. 観測結果をニューラルネットワークに通し、各行動の価値を出力する。

  3. 確率 $\varepsilon でランダムな行動を、確率 $1-\varepsilon で行動価値最大の行動を取る。新しい観測結果、報酬を受け取る。

  4. $Q(s_t, a_t)=r_{t+1}+\gamma\max_aQ(s_{t+1}, a) となる方向にニューラルネットワークを更新する。すなわち、誤差関数を $\mathit{loss}:=\bigl\{r_{t+1}+\gamma\max_aQ(s_{t+1}, a)-Q(s_t, a_t)\bigr\}^2 とし、これを最小化する方向に更新する。

    1. に戻る

環境

環境としては、CartPole-v0という、gym環境にデフォルトで付属してるやつを使いました。 小学校でよくやる、箒を逆さに立てて倒さないようにするあれです。 まあこの環境では箒ではなくて棒なんですが。

観測結果は要素数4のリスト(棒の位置・速度・角度・角速度)で、行動は2種類(棒を左に動かす・右に動かす)です。

エージェント

エージェントのニューラルネットワークは全結合層3層としました。

実装例

これを実装すると、以下のような感じになります。

agent.py

import torch
import torch.nn as nn
import torch.nn.functional as F

class Agent(nn.Module):
    def __init__(self, hidden):
        super().__init__()

        self.fc1 = nn.Linear(4, hidden)
        self.fc2 = nn.Linear(hidden, hidden)
        self.fc3 = nn.Linear(hidden, 2)

    def forward(self, x):
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        return self.fc3(x)

train.py

from random import random

import gym
import tensorboardX as tbx
import torch
import torch.nn.functional as F
import torch.optim as opt
from tqdm import tqdm

from agent import Agent


# ハイパーパラメータ
HIDDEN_NUM = 32  # エージェントの隠れ層のニューロン数
EPISODE_NUM = 2000  # 何エピソード実行するか
GAMMA = .99  # 時間割引率

agent = Agent(HIDDEN_NUM)
env = gym.make('CartPole-v0')
optimizer = opt.Adam(agent.parameters())


# 状態を受け取り、行動価値最大の行動とその行動価値を返す
def calc_q(obs, train=False, random_act=False):
    obs = torch.tensor(obs, dtype=torch.float32).unsqueeze(0)
    if train:
        agent.train()
        qs = agent(obs).squeeze()
    else:
        agent.eval()
        with torch.no_grad():
            qs = agent(obs).squeeze()

    if random_act:
        action = env.action_space.sample()
    else:
        action = torch.argmax(qs).item()

    return action, qs[action]


def do_episode(epsilon):
    obs = env.reset()
    done = False
    reward_sum = 0

    while not done:
        # 確率epsilonでランダム行動
        action, q = calc_q(obs, train=True, random_act=(random() < epsilon))
        next_obs, reward, done, _ = env.step(action)

        reward_sum += reward

        # エージェントを更新
        if done:
            next_q = torch.zeros((), dtype=torch.float32)  # doneなら次の状態は存在せず行動価値も0
        else:
            _, next_q = calc_q(next_obs)  # max_{a} Q(s_{t+1}, a)
        loss = F.mse_loss(q, reward + GAMMA*next_q)

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        obs = next_obs

    return reward_sum


if __name__ == '__main__':
    with tbx.SummaryWriter() as writer:
        for episode in tqdm(range(1, EPISODE_NUM + 1)):
            epsilon = .5 - episode * .5 / EPISODE_NUM  # epsilonを線形に小さくする
            reward_sum = do_episode(epsilon)
            writer.add_scalar('data/reward_sum', reward_sum, episode)

結果

全2000エピソード実行し、各エピソードで得られた報酬の和(割り引いていない和)のグラフを以下に示します。 横軸はエピソード番号、縦軸は報酬和です。 なお、CartPole-v0は、1ステップごとに、棒が倒れていなければ報酬1が貰えます。 200ステップするとゲームクリアということで強制的に終了する(doneTrueになる)ので、最大報酬は200です。

エピソードが進むにつれて学習も進んでいき、(バラツキはあるものの)エピソード1000あたりから200ステップ棒を立たせ続けることができるようになっています。

f:id:gpageinin:20200118155459p:plain
報酬のグラフ

まとめ

単純なDQNのイメージと実装を紹介しました。 最後におさらいすると、

  • エージェントは(ある状態における)行動価値 $Q(s, a) を出力し、基本的にそれが最大になるように行動する
  • $Q(s_t, a_t)=r_{t+1}+\gamma\max_aQ(s_{t+1}, a) となるようにする

というのがDQNの要点です。

ただ、今回の問題は簡単だったので工夫せずとも学習が進みましたが、難しい問題はこのままではなかなか上手くいきません。 また、学習が進んでいても結果にバラツキがあるのも難点です。

これを解消するために、DQNには様々な工夫が取り入れられています。 特に、それらを全部入りにした「Rainbow」というやつが有名です。

次回は、このRainbowに取り入れられている工夫のイメージ説明と実装例を扱います。

強化学習の実装例とその解説

強化学習の基礎シリーズで、基礎的な考え方と、それを実装するとどんな感じになるのか、というところを説明しました。 ただ、流れの理解の妨げにならないよう、長くなったり冗長になったりしそうな実装は省いて説明しました。

そこで、この記事では、強化学習の基礎シリーズで説明したことに基づいた解説をできるだけ挟みつつ、コピペしてそのまま動く「完成形」の実装例を示したいと思います。 従って、以下の4記事を読んでいることを前提として説明します。

今回作るゲーム

今回は、8パズルを作ろうと思います。 プログラミングの授業でこれを解くプログラムを作ったりしますね。 今回は、これを強化学習で解くプログラムを作ります。 ルールについては、Wikipediaの15パズルの記事とかを参照してください。

もちろん、本当は強化学習など使う必要もない問題ですが、例ということで許してください。

また、今回作ったプログラムは、Githubに置いてあります。

環境

まずは環境を作ります。 環境とはゲームそのものであるので、すなわち8パズルのゲームを実装するということになります。

以下で、実装の核となる部分を軽く説明してから、実装例を紹介します。 実装に伴う細かい部分や補足情報については、実装例の後に書きます。

観測

今回は、エージェントは環境(ゲーム)の全ての情報を得ることができます。 完全情報ゲームというやつですね。 将棋なんかもこれに当てはまり、割とよくあります。

というわけで、観測で得られる情報は盤面全体の情報になります。 これを、要素数9のリストで表します。 図の場合、

[2, 5, 0, 8, 4, 3, 6, 1, 7]

とします。 従って、observation_space

gym.spaces.Box(0, 8, (9, ), np.uint8)

となります。 ただし、紹介した実装では一般化しやすいよう、正方形の1辺のマスの数としてNを使った実装としています。

2 5
8 4 3
6 1 7

行動

行動は、空きマスを上下左右に動かすと考えます。 すなわち、4種類の行動が取れるものとします。 従って、action_space

gym.spaces.Discrete(4)

となります。

報酬

報酬は、

  • パズルが完成したら1の報酬を得る
  • 不可能な操作(空きマスが右下にあるのに、さらに右に動かそうとする場合など)をしようとした場合、-1の報酬を得る
  • その他の場合の報酬は0である

とします。 従って、reward_range

(-1, 1)

となります。

終了条件

doneは、パズルが完成したときと、不可能な操作をした場合にのみTrueになるものとします。


ディレクトリ構成

  • gym-puzzle/
    • README.md
    • setup.py
    • gym_puzzle/
      • __init__.py
      • envs/
        • __init__.py
        • puzzle_env.py

各ファイルの実装

setup.py

from setuptools import setup

setup(name='gym_puzzle',
            version='1.0.0',
            install_requires=['gym', 'numpy']
)

gym_puzzle/__init__.py

from gym.envs.registration import register

register(id='puzzle-v0',
         entry_point='gym_puzzle.envs:PuzzleEnv'
)

gym_puzzle/envs/__init__.py

from gym_puzzle.envs.puzzle_env import PuzzleEnv

gym_puzzle/envs/puzzle_env.py

import gym
import numpy as np
from gym import spaces

class PuzzleEnv(gym.Env):
    N = 3  # パズルの1辺の長さ
    metadata = {'render.modes': ['human', 'ansi']}
    observation_space = spaces.Box(0, N**2 - 1, (N**2, ), np.uint8)
    action_space = spaces.Discrete(4)
    reward_range = (-1, 1)

    goal_field = np.arange(N**2)  # 盤面の完成形

    def __init__(self):
        self.field = None
        self.empty_idx = None
        self.already_done = None
        self.rng = None

        self.seed()

    def step(self, action):
        if self.already_done:
            return self.field.copy(), 0, True, {}

        if self._cannot_act(action):
            return self.field.copy(), -1, True, {}

        if action == 0:  # 空きマスを上に
            self._swap(self.empty_idx, self.empty_idx - self.N)
        elif action == 1:  # 空きマスを右に
            self._swap(self.empty_idx, self.empty_idx + 1)
        elif action == 2:  # 空きマスを下に
            self._swap(self.empty_idx, self.empty_idx + self.N)
        elif action == 3:  # 空きマスを左に
            self._swap(self.empty_idx, self.empty_idx - 1)
        else:
            raise ValueError('未対応のaction')

        if self._clear():
            reward = 1
            done = True
        else:
            reward = 0
            done = False
        return self.field.copy(), reward, done, {}

    def reset(self):
        # fieldをランダムに初期化(解が存在する盤面に必ずなる)
        self.field = self.goal_field.copy()
        self.rng.shuffle(self.field)
        self._set_empty_idx()
        if not self._solvable():
            self._fliplr()

        self.already_done = False
        return self.reset() if self._clear() else self.field.copy()  # 最初から完成形にならないようにしている

    def render(self, mode='human'):
        if mode == 'human':
            for i in range(self.N):
                print(self.field[self.N * i : self.N * (i+1)])
        elif mode == 'ansi':
            return ','.join(map(str, self.field))
        else:
            raise ValueError('未対応のmode')

    def seed(self, seed=None):
        self.rng, seed = gym.utils.seeding.np_random(seed)
        return [seed]

    def _solvable(self):
        nonzero_field = self.field[self.field.nonzero()]
        tmp = 0
        for i in range(self.N**2 - 1):
            tmp += len(np.where(nonzero_field[i + 1:] < nonzero_field[i]))
        tmp += self.empty_idx // self.N

        return tmp % 2 == 1

    def _fliplr(self):
        # 盤面を左右反転する
        for i in range(self.N):
            self.field[self.N * i : self.N * (i+1)] = np.flip(self.field[self.N * i : self.N * (i+1)]).copy()
        self._set_empty_idx()

    def _swap(self, idx1, idx2):
        self.field[idx1], self.field[idx2] = self.field[idx2], self.field[idx1]

    def _cannot_act(self, action):
        if action == 0:  # 空きマスを上に
            impossible_idxes = [0, 1, 2]
        elif action == 1:  # 空きマスを右に
            impossible_idxes = [2, 5, 8]
        elif action == 2:  # 空きマスを下に
            impossible_idxes = [6, 7, 8]
        else:  # 空きマスを左に
            impossible_idxes = [0, 3, 6]
        return self.empty_idx in impossible_idxes

    def _clear(self):
        return np.array_equal(self.field, self.goal_field)

    def _set_empty_idx(self):
        self.empty_idx = np.where(self.field == 0)[0][0]
  • resetstepでリストを返すとき、環境内のリストが変更されないよう、コピーして返す。
  • 8パズルは完成不可能な場合があるので、reset時にはそうならないように注意する(こちらのサイトを参考にしました)。
  • reset時に、盤面が最初から完成形にならないように注意する。

エージェント

さて、環境の次はエージェントです。 今回は、入力に対して施す演算部分であるニューラルネットワークを、全結合層3層のネットワークとします。

また、これは機械学習ではよくやる話なのですが、行動を直接出力するのではなく、各行動を取る確率を出力とします。 今回は行動の種類が4つあるので、[0.2, 0.4, 0.1, 0.3]みたいな感じです。 これはsoftmax関数を使うことでできます。

と言っておいてなんですが、さらに今回は確率をそのまま出力するのではなく、確率にlogを施した値を出力とします。 これもよくやる手法です。 PyTorchには、これ用の関数log_softmaxがあるので、これを使います。

import torch
import torch.nn as nn
import torch.nn.functional as F

class Agent(nn.Module):
    def __init__(self, n, hidden):
        super().__init__()

        self.fc1 = nn.Linear(n**2, hidden)
        self.fc2 = nn.Linear(hidden, hidden)
        self.fc3 = nn.Linear(hidden, 4)

    def forward(self, x):
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        return F.log_softmax(self.fc3(x), dim=-1)

学習本体

最後に、環境とエージェントを用いて強化学習するコードを書きます。 今回は方策勾配法を用います。 gym環境の作り方とは違い、学習手法についてはいくらでも分かりやすい記事や本があるので、ここでは詳しく触れません。 機会とやる気があれば別の記事で書きます。

import gym
import numpy as np
import tensorboardX as tbx
import torch
import torch.optim as opt
from tqdm import tqdm

import gym_puzzle
from agent import Agent


# ハイパーパラメータ
HIDDEN_NUM = 128  # エージェントの隠れ層のニューロン数
EPISODE_NUM = 10000  # エピソードを何回行うか
MAX_STEPS = 1000  # 1エピソード内で最大何回行動するか
GAMMA = .99  # 時間割引率

env = gym.make('puzzle-v0')
agent = Agent(env.N, HIDDEN_NUM)
optimizer = opt.Adam(agent.parameters())


def do_episode():
    obs = env.reset()
    obss, actions, rewards = [], [], []

    # 現在の方策で1エピソード行動する
    agent.eval()
    with torch.no_grad():
        for step in range(1, MAX_STEPS + 1):
            obs = torch.tensor([obs], dtype=torch.float32)
            obss.append(obs)

            prob = agent(obs)[0].exp().numpy()
            action = np.random.choice(range(4), p=prob)
            obs, reward, done, _ = env.step(action)

            actions.append(torch.eye(4, dtype=torch.float32)[action])  # 後の都合でone-hot形式で保存
            rewards.append(reward)

            if done:
                break

    # 割引報酬和を求める
    cum_rewards = [0]
    for i, r in enumerate(rewards[::-1]):
        cum_rewards.append(GAMMA*cum_rewards[i] + r)
    cum_rewards = cum_rewards[:0:-1]

    # lossを計算して返す
    agent.train()
    loss_sum = .0
    log_pis = [agent(o)[0] * a for (o, a) in zip(obss, actions)]
    for log_pi, r in zip(log_pis, cum_rewards):
        loss_sum = loss_sum - (log_pi * r).sum()

    return loss_sum / len(obss)


if __name__ == '__main__':
    with tbx.SummaryWriter() as writer:
        for episode in tqdm(range(1, EPISODE_NUM + 1)):
            loss = do_episode()

            # lossを用いて方策を更新する
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()

            writer.add_scalar('loss/loss', loss.item(), episode)

    torch.save(agent.state_dict(), 'agent.tar')

学習結果

学習してみましたが、まったく学習が進みませんでした。 なので、実装を書いておいてなんなんですが、学習本体部分は間違っている可能性が結構あります。

考えられる原因は、

  • (正の)報酬がまったくもらえず、学習の進みようがなかった
  • 方策勾配法の実装が間違っている
  • 学習時間不足
  • ハイパーパラメータ

くらいですかね。 上から確率70%、25%、3%、2%くらいだと思います。

まとめ

というわけで、間違っているかもしれない実装を紹介しました。 このシリーズで本当に書きたかったのはgym環境の作り方なので、学習部分はおまけということで許してください。 何でもはしません。

強化学習の基礎(その4:終)

前回の記事の続きです。

強化学習に必要な環境エージェントのうち、環境の実装を前回説明しました。 今回は、残りのエージェントと、学習本体の説明をします。

エージェント

エージェントの役割は、「観測結果をもとに、(望ましい)行動をすること」でした。 なので、観測結果を受け取り、そこに何らかの演算を施して、行動を出力できればいいです。 この「何らかの演算」の部分を、普通はニューラルネットワークを用いて実現します。 ここではPyTorchを使った実装を紹介します。

import torch
import torch.nn as nn
import torch.nn.functional as F

class Agent(nn.Module):
    def __init__(self):
        self.conv1 = nn.Conv2d(3, 16, 3, padding=1)
        self.conv2 = nn.Conv2d(16, 16, 3, padding=1)
        self.conv3 = nn.Conv2d(16, 32, 3, padding=1)
        self.conv4 = nn.Conv2d(32, 32, 3, padding=1)
        self.fc1 = nn.Linear(32 * 400 * 225, 1024)
        self.fc2 = nn.Linear(1024, 7)

    def forward(self, x):
        x = F.relu(self.conv1(x))
        x = F.relu(self.conv2(x))
        x = F.max_pool2d(x, 2)
        x = F.relu(self.conv3(x))
        x = F.relu(self.conv4(x))
        x = F.max_pool2d(x, 2)
        x = x.view(-1, 32 * 400 * 225)
        x = F.relu(self.fc1(x))
        return F.softmax(self.fc2(x))

CNNに通し、その後全結合層を2つ通す簡単なネットワークです。 forwardメソッドが演算を行う部分となります。 ここらへんの詳しい内容の説明は本筋からそれるので省略します(気が向いたら別記事で書きます)。

学習本体

さて、環境とエージェントが揃ったので、あとはこれらを作用させて学習を行う、学習本体部分を用意すれば完了です。 ここでは、流れ・使用方法が見やすいように抽象的な実装例を紹介します。

import gym
from agent import Agent

import gym_tetris

agent = Agent()
env = gym.make('tetris-v0')

def do_episode():
    obs = env.reset()
    done = False
    while not done:
        action = agent(obs)
        obs, reward, done, _ = env.step(action)
        # lossの計算(更新)

    return loss

if __name__ == '__main__':
    for epoch in range(10000):
        loss = do_episode()
        # lossを用いてagentを更新

学習の流れは、以下のようになります。

  1. 作成したエージェントと環境を用意する。環境は、gym.makeメソッドを用います。引数には、gym_tetris/__init__.py'id'にセットした文字列を渡します。

  2. 適当な回数、「1エピソード実行→得たlossを用いてエージェントを更新」を繰り返す。

ちなみに「エピソード」とは、スタート(reset呼び出し直後)から、doneTrueになるまでを指します。 実装例のdo_episodeメソッドを1回呼び出すのが、1エピソードを実行するのに対応しています。

また、do_episodeメソッド内で、1回行動する毎にエージェントを更新する、という場合もあります。 これは学習アルゴリズムに依ります。

まとめ

これで強化学習の基礎シリーズは完結です。 機械学習強化学習の基礎と、強化学習に必要な環境・エージェントの実装、そして学習本体の抽象的な実装例を紹介しました。 特に環境の実装は、エージェントの実装に比べて情報が少ないような気がしたので、ちょっと詳しめに書きました。

とはいえ、いくつか実装を放棄した部分もあり、そこが分からんねん!という意見もあるかと思います。 てなわけで、気が向いたらコピペしてそのまま動く実装と、今回のシリーズに基づいた解説も別記事で書きます。

それでは。

強化学習の基礎(その3)

注意:この記事には嘘が含まれています!!!(近く修正します)

前回の記事の続きです。

さて、前回までの記事で、強化学習の具体的なモデルまで説明し終えました。 環境エージェントの相互作用という話でしたね。 今回は、この2つのうち、環境の実装について説明します。 なお、前回に引き続きテトリスをプレイするエージェントの作成を例とします。

OpenAI Gym

強化学習の環境と言えばこれ!というやつです。 環境の実装時にいくつかの条件を満たすことで、「OpenAI Gym形式の環境である」ということができます。 便宜上、「OpenAI Gym形式の環境」を「gym環境」と呼ぶことにします。

大抵の環境の実装はOpenAI Gym形式だと思われますし、(今回は扱いませんが)エージェントもgym環境を仮定して実装されていることが多いです。 gym環境を作ると、誰かが書いたエージェントの実装を利用することができ、実装しなくてはならない量が減ります。

gym環境の作成

準備

Python本体とその知識(基本文法とクラスが分かるくらい)は必要です。 あと、分かる人には当然かと思いますが、virtualenvとかpipenvとかで仮想環境を作ってその中でやります。 詳細は本題からそれるので割愛します。

で、

pip install gym

でgymをインストールすれば準備完了です。

あと、ほとんどの場合numpyを使うので、

pip install numpy

でこちらもインストールしておきます。

ディレクトリ構成

まず、環境用のディレクトリを作成します。 ディレクトリ名はgym-tetrisとします。

作成したら、以下のような構成になるように、各ディレクトリ・ファイルを作成してください。

  • gym-tetris/
    • README.md
    • setup.py
    • gym_tetris/
      • __init__.py
      • envs/
        • __init__.py
        • tetris_env.py

ファイルの中身は以下のようにします。

  • setup.py
from setuptools import setup

setup(name='gym_tetris',
            version='1.0.0',
            install_requires=['gym', 'numpy']
)
  • gym_tetris/__init__.py
from gym.envs.registration import register

register(id='tetris-v0',
         entry_point='gym_tetris.envs:TetrisEnv')
  • gym_tetris/envs/__init__.py
from gym_tetris.envs.tetris_env import TetrisEnv
  • gym_tetris/envs/tetris_env.py

これについては次に説明します。

さて、長々と書きましたが、重要なのは最後のtetris_env.pyです。 これが環境の本体になります。 他はいわゆるおまじないというやつなので、適当にtetrisの部分をリネームしつつほぼコピペでいいです。

環境本体

さて、本体のtetris_env.pyは、以下のようにします。 各メソッドなどの説明は実装の下に書きます。

import gym
from gym import spaces
import numpy as np

class TetrisEnv(gym.Env):
    metadata = {'render.modes': ['human', 'rgb_array']}
    observation_space = spaces.Box(0, 255, (3, 1600, 900), np.uint8)
    action_space = spaces.Discrete(7)
    reward_range = (-5, 4)

    def __init__(self):
        # 必要なフィールドの準備等

    def step(self, action):
        # エージェントの行動を環境に反映
        return obs, reward, done, info

    def reset(self):
        # ゲームをリセット
        return obs

    def render(self, mode='human'):
        if mode == 'human':
            # 環境を表示
        elif mode == 'rgb_array':
            return ary
        else:
            raise ValueError

    def close(self):
        # 環境を終了させる

    def seed(self, seed=None):
        self.rng, seed = gym.utils.seeding.np_random(seed)
        return [seed]

フィールドの説明

metadata

何かしら環境についての情報を外部から見えるところに置いておきたい場合、ここに入れます。 なので中身は環境によって様々なのですが、1つだけ、どの環境も持っておく方がいい情報があります。 後に説明する、renderメソッドの引数として使える文字列です。 詳しくはrenderメソッドのところで説明します。

observation_space

観測結果がどのような形状・値になるかを保持するフィールドです。 今回の例では、「フィールド部分の画像(RGBのピクセル集合)」を観測とします。 この場合、gym.spaces.Boxを用いて

gym.spaces.Box(0, 255, (3, 1600, 900), np.uint8)

と書けます。 引数の意味は以下の通りです。

  • 0255 : 観測結果が取りうる値の最小値・最大値
  • (3, 1600, 900) : 観測結果の形状(画面が縦1600×横900ピクセルであるとする)
  • np.uint8 : 観測結果の型

なお、形状は(1600, 900, 3)とすることもあります。 これはどちらでもいいです。 いわゆるCHWとHWCの違いというやつで、ここらへんは気になったら調べてください。 ちなみに、3はRGBで3チャンネル必要ということです。

また、Boxにはもう1つの形式の定義の仕方があるのですが、その説明は割愛します。

action_space

エージェントが取りうる行動を保持するフィールドです。 今回はテトリスなので、行動はどのボタンを押すか、ということになります。 具体的には、十字キー(4つ)・回転(2つ)・ホールドの、合計7操作があると思います。 このように、行動が離散的な場合は、gym.spaces.Discreteを用いて

gym.spaces.Discrete(7)

みたいにします。

なお、Discreteの実体はPython標準のrangeと同じ感じです。 すなわち、行動を数値0〜6で扱うことになります。

また、行動が連続値になる場合(レバー操作が必要で、どれくらいレバーを倒しているかを扱う場合など)は、先ほど用いたBoxaction_spaceを用意します。

reward_range

報酬が取りうる値の範囲を保持するフィールドです。 今回は例として、

  • ラインを消した場合、消したライン分の報酬を得る
  • ゲームオーバーになった場合、-5の報酬を得る
  • 上記に当てはまらない場合、報酬は0である

ということにします。 この場合、範囲は[-5, 4]になるので、これをタプルかリストかで保持します。 今回の例ではタプルにしましたが、どちらでもいいです。


ここまでが実装する必要のあるフィールドです。 ちなみに、これらは今回クラスフィールドとしていますが、Pythonはクラスフィールドとインスタンスフィールドを同じように扱えるので、インスタンスフィールドとして実装しても大丈夫です。

メソッドの説明

step(self, action)

行動を受け取り、環境に反映するメソッドです。 action_spaceの説明で書いた通り、今回は行動が数値なので、数値によって場合分けして環境を変化させます。

そして、前回の記事で書いた通り、「(観測したエージェントが)行動する→環境が変化する・報酬を得る・新たに観測する」というのが一連の流れです。 そこでこのメソッドは、環境を変化させるだけでなく、新しい観測結果obsと報酬rewardを返します。

さらに、実際にはもう2つの情報を返します。 1つ目はdoneで、これは環境が終了状態にあるかどうかをboolで返します。 テトリスの例では、ゲームオーバーになったらdoneTrueになります。 一度Trueとして返した場合、環境を利用する側はresetメソッドを呼ぶ義務があります。 逆に言うと、環境側は、一度Trueにして返したら、resetメソッドが呼ばれるまでの動作は保証しなくていいということです。

とはいえ、例外とかが投げられてしまうと不親切すぎるし、困る場合があるので、最低限動作はさせましょう。 次に説明するinfoに「doneが一度Trueになった」という情報を持たせるなどすると丁寧ですね。

2つ目はinfoで、こちらは何かしら追加で返したい情報を辞書で返します。 何もない場合は空の辞書を返します。

reset(self)

環境をリセットします。 テトリスの例では、「最初から始める」ボタンを押す感じです。

また、リセットした後、観測を返します。 テトリスの例では、何もテトリミノが置かれていない、まっさらなフィールドの画像が返るわけですね。

render(self, mode='human')

renderメソッドは、(観測ではなく)環境を確認するためのメソッドです。 学習に使うわけではなく、我々人間が学習が上手く進んでいるかどうかを確かめるためなどに用います。

引数として、どのように環境を表現するかを指定します。 別に数値でも文字列でもなんでもいいんですが、慣習的によく使われるのは、以下の3つです。

  • 'human' : 我々人間に向けて環境を表示する。要は画面を表示する。
  • 'rgb_array' : RGBのリスト(3次元)を返す。
  • 'ansi' : 文字列もしくはStringIOインスタンスを返す。テトリスの例では環境を表す文字列を考えるのは難しいため、実装していない。

また、今回の例も当てはまりますが、これら全てを実装しないこともあります。 そのため、環境の利用者がどの引数が使えるか分かるように、フィールドmetadataに取りうるmodeの種類を保存しておきます。

close(self)

環境を閉じるメソッドです。 プログラム終了時などに勝手に呼ばれます。 ゲーム用のプロセスを立ち上げていた際に、それをkillするなどの作業をします。

ちなみに、このメソッドと次に説明するseedメソッドは実装が必須ではありません。 実装しない場合、何もしないメソッドとなります。

seed(self, seed=None)

環境内で乱数を用いる場合に、そのシードを設定するためのメソッドです。 実装例に示したやつのコピペでいいですし、ほとんどの場合実装しなくても大丈夫です。

gym環境の利用準備

さて、こうして作った環境ですが、これを使うには、まずpipを使って環境をインストールする必要があります。 具体的には、gym-tetrisディレクトリをカレントディレクトリとして、そこで

pip install -e .

すればいいです。

これで環境のインストールが完了したので、あとはエージェントと学習本体のコードを実装すれば強化学習ができる状態となります。

エージェントと学習本体の実装紹介は、次回の記事になります。 3回じゃ終わらなかったですね。