LoginSignup
15
12

More than 3 years have passed since last update.

【強化学習】R2D2を実装/解説してみたリベンジ 解説編(Keras-RL)

Last updated at Posted at 2020-05-10

以前実装したR2D2ですが、ミニバッチ学習の実装が出来ていませんでした。
その後試行錯誤し今回何とか実装しました。

以前の記事よりだいぶ間が開いてしまったので全体の流れに関してもざっくり説明していきます。
また、以前の実装で間違った箇所も修正していきます。。。
※ネット上の情報をかき集めて自分なりに実装しているので正確ではないところがある点はご了承ください。

また、本記事は解説編とハイパーパラメータ設定編の2部構成です。
ハイパーパラメータについては以下にて
【強化学習】R2D2を実装/解説してみたリベンジ ハイパーパラメータ解説編(Keras-RL)

追記:R2D3も実装しました。
【強化学習】R2D3を実装/解説してみた(Keras-RL)

追記2:Agent57も記事をあげました。
【強化学習】ついに人間を超えた!?Agent57を解説/実装してみた(Keras-RL)

コード全体

本記事で作成したコードは以下です。
今回は github のみです。

目次

  • DQN(Rainbow)の実装解説
  • R2D2の実装解説
  • その他
    • ImageModelの拡張
    • Policy(方策)の拡張

DQN(Rainbow)の実装解説

復習となりますが、DQN(Rainbow)の実装についてイメージを改めて説明します。
詳細な解説に関しては以前投稿した記事を参照してください。

DQN(Rainbow)での学習のイメージをまとめると以下になります。

zu1.PNG

zu2.PNG

DQNは経験データ(経験)を以下として memory に保存します。

$$e_{t} = (s_{t},a_{t},r_{t},s_{t+1})$$

Multi-step learning の step が1の場合は次の状態が $t+1$ になりますが、
3steps だと $t+3$ になります。

数式
前の状態 $s_{t}$ observation: t(n-6) ~ t(n-3)
次の状態 $s_{t+1}$ observation: t(n-3) ~ t(n)
アクション $a_{t}$ action: t(n-3)
報酬 $r_{t}$ reward: t(n)

また、各変数の内部で保持するサイズは以下となります。

保持する長さ memoryに保存する長さ
rewards multisteps 0(計算のみに使用)
計算済みrewards 1 1(現在の状態)
actions multisteps + 1 1(前の状態)
observations input_sequence + multisteps input_sequence + multisteps

Multi-Step learning で参照する action の間違い

以前の記事のMulti-Step learningですが、action の参照を $t_n$ で参照していましたが間違いですね…
前の状態の action を参照するので $t_{n-multisteps}$ が正解でした。

重要度サンプリングの間違い

以前の記事は以下です。

重要度サンプリングを簡単に言うと、Priority Experience Reply(優先順位付き経験再生)により経験を取り出す際に優先度がつきました。
すると取得する経験の回数に偏りが生じます。
そうするとその偏りで学習に bias がかかってしまうのでこれを修正するのが重要度サンプリングになります。

具体的には、高い確率で選ばれる経験はQ値の更新への反映率を低くし、低い確率で選ばれる経験はQ値の更新への反映率を高くするというものです。

以前は、微妙に実装がおかしいようでうまく学習しない状況でした。
以前は更新後のQ値そのものにかけていましたが、td_error 自体にかけるべきでした。(変数の命名が良くなかったですね)
また、Q値の更新に反映させるものなので priority には適用しないようにしています。

・以前の実装(疑似コード)

IS
def train():

    # PER から確率に従って経験を取得
    batchs, batch_weight = memory.sample(batch_size)

    # modelから前の状態のQ値を取得
    # state0_qvals は action 毎のQ値が入っている
    state0_qvals = model.predict(state0_batch)

    for batch_i in range(batch_size):
        reward = batchs[batch_i]の報酬
        action = batchs[batch_i]のアクション
        q0 = state0_qvals[batch_i][action]  # 更新前のQ値

        # modelとtarget_modelを使い、現在の状態の最大Q値を取得
        # (DQNとDDQNで取得方法が異なります)
        maxq = modelとtarget_modelから取得

        td_error = reward + (gamma ** reward_multisteps) * maxq
        td_error *= batch_weight

        priority = abs(td_error - q0)

        # 対象アクションのQ値のみを変更することで学習させる
        state0_qvals[batch_i][action] = td_error

    # train
    model.train_on_batch(state0_qvals)

・変更後の実装(疑似コード)
※ が変更箇所となります

IS
def train():

    # PER から確率に従って経験を取得
    batchs, batch_weight = memory.sample(batch_size)

    # modelから前の状態のQ値を取得
    # state0_qvals は action 毎のQ値が入っている
    state0_qvals = model.predict(state0_batch)

    for batch_i in range(batch_size):
        reward = batchs[batch_i]の報酬
        action = batchs[batch_i]のアクション
        q0 = state0_qvals[batch_i][action]  # 更新前のQ値

        # modelとtarget_modelを使い、現在の状態の最大Q値を取得
        # (DQNとDDQNで取得方法が異なります)
        maxq = modelとtarget_modelから取得

        #※ - q0 を追加し、ちゃんと td_error を出す
        #※ また、batch_weight の適用をここではなしにする
        td_error = reward + (gamma ** reward_multisteps) * maxq - q0

        #※ td_errorの絶対値がそのまま priority になる
        priority = abs(td_error)

        # 対象アクションのQ値のみを変更することで学習させる
        #※ td_error が差分になったので、そこにweightを適用し、差分でQ値を更新する
        state0_qvals[batch_i][action] += td_error * batch_weight

    # train
    model.train_on_batch(state0_qvals)

R2D2 の実装解説

ミニバッチ学習

以前ミニバチ学習が実装できなかったのは Keras のステートフルLSTMの理解が浅かったからです。
以前の調査記事は以下になります。

zu3.PNG

どうやら hidden_states の中に batch_size 分状態があり指定できるようです。
これで sequence 間の学習を複数同時に進めることができました。

DRQN(R2D2)

話を分かりやすくするために並列処理部分をはぶいたR2D2で説明します。
以前の記事は以下です。

DQNと同様にイメージ図です。

zu4.PNG

zu5.PNG

かなり複雑になりました…。
この図を書いたのも実装してるときに自分が混乱したからですね…。

Q値の更新及び Priority の出し方は DQN と同様なので図からは省略しています。

ポイントは input sequence と input length です。
前回はこれを意識していませんでした。
(input sequence=1と仮定し、input length を input sequence と表現していた)

input sequence は model に入力する状態の長さで、それを入力する数が input length となります。
input length 毎にQ値を更新し、Priorityも計算します。
(この解釈は少し自信がないですが、R2D2の論文2.3節で Priority の新しい出し方を提案しており、上記のように1つの経験から複数の Priority が出ると考えると筋が通ります)

各変数の内部で保持するサイズは以下となります。

保持する長さ memoryに保存する長さ
rewards multisteps + input_length - 1 0(計算のみに使用)
計算済みrewards input_length input_length
actions multisteps + input_length input_length(前の状態から)
hidden states burnin + multisteps + input_length + 1 1(一番古い状態)
observations burnin + input_sequence + multisteps + input_length - 1 0(下でまとめる用)
まとめたobservations burnin + multisteps + input_length 同じ長さ

rescaling 関数

$$h(x) = sign(x)(\sqrt{|x|+1}-1)+\epsilon x$$

rescaling2.png

rescaling 関数はR2D2で導入された関数で、報酬のクリッピング(-1~1)の代わりに用いるという事でした。
以前は逆関数で悩んでいましたがちょっと強引に不要にしました。

rescaling 関数を使ってTD誤差を導出する式は以下です。($y_t$がTD誤差です)

$$y_{t} = h \Bigl(r_{t} + \gamma h^{-1}(\max_pQ_{target}(s_{t+1},a_{t}))\Bigr)$$

上記式の $h()$ を展開?します。

$$y_{t} = h (r_{t}) + h \Bigl(\gamma h^{-1}(\max_pQ_{target}(s_{t+1},a_{t}))\Bigr)$$

ある関数に対して逆関数を適用すると元の値に戻ります。※ $h(h^{-1}(x)) = x$
ですので右辺は相殺できます($\gamma$は誤差として無視しています…)

すると以下になります。

$$y_{t} = h (r_{t}) + \gamma (\max_pQ_{target}(s_{t+1},a_{t}))$$

rescaling 関数の適用先が報酬($r_{t}$)だけになりました。
グラフを見ると報酬がいい感じに丸められてるのが分かります。(100の報酬が10ぐらいになる)
クリッピングの代わりというのも納得です。

並列処理(プロセス間通信)

以前の記事は以下です。

参考:Pythonのthreadingとmultiprocessingを完全理解

最初は Queue を使っていたのですが、weights のデータ量が多くボトルネックになってそうだったので、各プロセス間通信に関して調査してみました。
調査結果は以下の記事です。

これより、以下のような通信にしました。(結果としてはQueueをそのまま使っています)

zu10.PNG

zu11.PNG

zu12.PNG

プロセス間の情報のやりとりは共有メモリで実装しています。
書き込む人と読む人が明確に分かれているためロックも特にしていません。

Callbacks

プロセス間通信に結構コストがかかることが分かったのと、
ActorとLeanerにまたがる処理があったので実装しました。

主に save/load とログのために作成しています。
実装したCallbackの基底クラスは以下です。

R2D2Callback
import rl.callbacks
class R2D2Callback(rl.callbacks.Callback):
    def __init__(self):
        pass

    #--- train ---

    def on_r2d2_train_begin(self):
        pass

    def on_r2d2_train_end(self):
        pass

    #--- learner ---

    def on_r2d2_learner_begin(self, learner):
        pass

    def on_r2d2_learner_end(self, learner):
        pass

    def on_r2d2_learner_train_begin(self, learner):
        pass

    def on_r2d2_learner_train_end(self, learner):
        pass

    #--- actor ---
    # 下記および rl.callbacks.Callback の継承メソッド

    def on_r2d2_actor_begin(self, actor_index, runner):
        pass

    def on_r2d2_actor_end(self, actor_index, runner):
        pass

見てわかる通り、Keras-rl の Callback を継承しています。
これは Agent でそのまま使われます。

注意点として、train、learner、actor が別のプロセスから呼ばれる事を想定している点です。
なのでこれらをまたがる処理を書いてもプロセスが違うので値は保持されません。

これらを使った save/load と log についてはパラメータ編で説明します。

GPU

tensorflow 2.1.0 でそのままGPU実行すると以下のようなエラーがでました。

tensorflow.python.framework.errors_impl.InternalError:  Blas GEMM launch failed : a.shape=(32, 12), b.shape=(12, 128), m=32, n=128, k=12     

どうやら複数のプロセスでGPUを使うと出るエラーらしいです。
下記を参考に複数のプロセスでGPUを使用する設定をしています。

# 全プロセスに設定してほしいでの、グローバルに記載
for device in tf.config.experimental.list_physical_devices('GPU'):
    tf.config.experimental.set_memory_growth(device, True)

また、R2D2Managerの内部でCPUかGPUかを自動で判別する処理を書いています。

import tensorflow as tf

def train(self):
    (省略)
    if len(tf.config.experimental.list_physical_devices('GPU')) > 0:
        self.enable_GPU = True
    else:
        self.enable_GPU = False
    (省略)

その他の実装

ImageModel の拡張

NN(ニューラルネットワーク)における画像の処理層は DQN から変化がありません。
ですのでここを変更できるように拡張しました。

DQN におけるNN層は以下のような階層です。

概要
1 入力層
2 入力変換層 入力の形式を一般化する層
3 画像処理層 画像処理の場合
4 LSTM層 LSTMを使用する場合
5 dueling network層 dueling networkを使用する場合
6 Dense層 dueling networkを使用する場合は包含される
7 (出力層) 実際にはdueling network層に含まれる

入力変換層の一般化

入力変換層は入力形式に対して1次元の出力(Flatten)にする層です。
以下の4種類の入力を想定して作成しています。

InputType
import enum
class InputType(enum.Enum):
    VALUES = 1    # 画像無し
    GRAY_2ch = 3  # (width, height)
    GRAY_3ch = 4  # (width, height, 1)
    COLOR = 5     # (width, height, ch)

画像無しの入力層(VALUES)(LSTMなし)

そのまま Flatten するだけです。

input_sequence = 4
input_shape = 3

c = Input(shape=(input_sequence,) + input_shape)
# output_shape == (None, 4, 3)

c = Flatten()(c)
# output_shape == (None, 12)

画像無しの入力層(VALUES)(LSTMあり)

同じくそのまま Flatten するだけです。
timestepsを保持するために TimeDistributed でラップしています。

batch_size = 16
input_sequence = 4
input_shape = 3

c = Input(batch_shape=(batch_size, input_sequence,) + input_shape)
# output_shape == (16, 4, 3)

c = TimeDistributed(Flatten())(c)
# output_shape == (16, 4, 3)

グレー画像(チャンネル無し)の入力層(GRAY_2ch)(LSTMなし)

DQN で使われている変換ですね。
チャンネルを input_sequence(入力サイズ)に置き換えます。

input_sequence = 4
input_shape = (84, 84)  #(widht, height)

c = Input(shape=(input_sequence,) + input_shape)
# output_shape == (None, 4, 84, 84)

c = Permute((2, 3, 1))(c)  # 順序を入れかえる層
# output_shape == (None, 84, 84, 4)

c = 画像処理層(c)

グレー画像(チャンネル無し)の入力層(GRAY_2ch)(LSTMあり)

LSTMが有効の場合は sequence 情報を timesteps で補う事が出来るので、
チャンネル層を増やしています。

batch_size = 16
input_sequence = 4
input_shape = (84, 84)  #(widht, height)

c = Input(batch_shape=(batch_size, input_sequence,) + input_shape)
# output_shape == (16, 4, 84, 84)

c = Reshape((input_sequence, ) + input_shape + (1,) )(c)  # チャンネル層を追加
# output_shape == (16, 4, 84, 84, 1)

c = 画像処理層(c)

画像(チャンネルあり)の入力層(GRAY_3ch、COLOR)(LSTMなし)

そのまま画像処理層に渡します。
ただ、input_sequenceの情報は表現できません。

input_sequence = 4
input_shape = (84, 84, 3)  #(widht, height, channel)

c = Input(shape=input_shape)
# output_shape == (None, 84, 84, 3)

c = 画像処理層(c)

画像(チャンネルあり)の入力層(GRAY_3ch、COLOR)(LSTMあり)

違いはありません。

batch_size = 16
input_sequence = 4
input_shape = (84, 84, 3)  #(widht, height, channel)

c = Input(batch_shape=(batch_size, input_sequence,) + input_shape)
# output_shape == (16, 4, 84, 84, 3)

c = 画像処理層(c)

画像処理層の一般化

ImageModel クラスを定義して層を変更できるようにしています。

create_image_model の引数cは以下の形式で渡されます。

LSTMなし:shape(batch_size, width, height, channel) 
LSTMあり:shape(batch_size, timesteps, width, height, channel)

戻り値は以下の形式にする必要があります。

LSTMなし:shape(batch_size, dim) 
LSTMあり:shape(batch_size, timesteps, dim)

以下は DQN 形式の例です。

DQNImageModel
class DQNImageModel(ImageModel):
    """ native dqn image model
    https://arxiv.org/abs/1312.5602
    """

    def create_image_model(self, c, enable_lstm):
        """
        c shape(batch_size, width, height, channel)
        return shape(batch_size, dim)
        """

        if enable_lstm:
            c = TimeDistributed(Conv2D(32, (8, 8), strides=(4, 4), padding="same"), name="c1")(c)
            c = Activation("relu")(c)

            c = TimeDistributed(Conv2D(64, (4, 4), strides=(2, 2), padding="same"), name="c2")(c)
            c = Activation("relu")(c)

            c = TimeDistributed(Conv2D(64, (3, 3), strides=(1, 1), padding="same"), name="c3")(c)
            c = Activation("relu")(c)

            c = TimeDistributed(Flatten())(c)

        else:

            c = Conv2D(32, (8, 8), strides=(4, 4), padding="same", name="c1")(c)
            c = Activation("relu")(c)

            c = Conv2D(64, (4, 4), strides=(2, 2), padding="same", name="c2")(c)
            c = Activation("relu")(c)

            c = Conv2D(64, (3, 3), strides=(1, 1), padding="same", name="c3")(c)
            c = Activation("relu")(c)

            c = Flatten()(c)
        return c

Policy(方策)の拡張

以前の解説記事は以下です。

DQNでは ε-greedy 法の探索ポリシーしか使われませんが、上記記事でいくつか紹介したポリシーがあります。
一応それらも使えるように実装しましたが…、 ε-greedy だけあれば十分なような…。
詳細はパラメータ編で説明予定です。

あとがき

とりあえず実装させました。
次回は各パラメータについてどう設定すればいいのかサンプルを記事にしたいと思います。

論文リンク集

15
12
10

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
12