pytorchでindexのリストを指定してtensorの要素を取得

Double DQNの実装に必要になるちょっとした計算についてメモ

2つの2次元tensor x, yを用意し、"xの各行において最大の値を持つ要素"と同じ位置にあるyの要素を取得する

>>> x = torch.rand(3,5)
>>> x
tensor([[ 0.0778,  0.6633,  0.4953,  0.1461,  0.4691],
        [ 0.3024,  0.0295,  0.3526,  0.6040,  0.7512],
        [ 0.1778,  0.7783,  0.1738,  0.5278,  0.0372]])

>>> x.argmax(1)
tensor([ 1,  4,  1])

>>> x.argmax(1, keepdim=True)
tensor([[ 1],
        [ 4],
        [ 1]])

>>> y = torch.rand(3,5)
>>> y
tensor([[ 0.4005,  0.3994,  0.1083,  0.8888,  0.9239],
        [ 0.6046,  0.5906,  0.3089,  0.4983,  0.2159],
        [ 0.4500,  0.9791,  0.4029,  0.9614,  0.5124]])

>>> y.gather(1, x.argmax(1, keepdim=True))
tensor([[ 0.3994],
        [ 0.2159],
        [ 0.9791]])

ちなみに、最後にsqueezeをかければ単純な1次元のリストにできる

dueling networkを調べつつpytorchで実装

強化学習の性能を改善する方法の一つであるdueling networkについて調べたのでメモ。まず参考にしたのはこちらのqiitaの記事

【深層強化学習】Dueling Network 実装・解説

詳細な部分についてはこちらの論文を参考にした

[1511.06581] Dueling Network Architectures for Deep Reinforcement Learning

大枠としてやっていることは

  • ある状態においてagentのactionが結果に影響を与える場合と与えない場合を区別して計算する

ということ。

例えばレースゲームにおいて、真横に他の車がいる場合にそちらハンドルを切ればぶつかってクラッシュしてしまう。つまり自分の行動次第で得られる報酬は大きく変化する。一方、まわりに車がいない状態であればハンドルを切るという行動は結果にさほど影響を与えないといえる。

通常のDQNでは一つの全結合層を挟んでaction数分の出力を得るのみだが、dueling networkではそれに加えてもう一つ別の全結合層を用意して1つの出力も得る。

前者の出力をadvantage function A(s,a)、後者の出力をvalue function V(s)として、それらを足し合わせた結果として最終的な出力であるQ function Q(s,a)を得る。

\displaystyle Q(s,a) = V(s) + A(s,a) - \frac{1}{|A|}\sum_{a} A(s,a)

論文ではもっと厳密に書いてあるが、簡単のため直接関わらないθなどのパラメータは省略した。最後の項はすべてのactionに対してAの和をとってその要素数で割っているので、要するにAの平均値。

*追記
論文で書いてある実装と異なっていることに気づいたためネットワーク構成を修正しました。valueとadvantageの両方にそれぞれhidden layerが存在する形へ。

The value and advantage streams both have a fullyconnected layer with 512 units. The final hidden layers of the value and advantage streams are both fully-connected with the value stream having one output and the advantage as many outputs as there are valid actions2

これを実装したのが以下

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

class DuelingNetFC(nn.Module):
    def __init__(self, num_states, num_actions):
        super(DuelingNetFC, self).__init__()
        self.num_states = num_states
        self.num_actions = num_actions

        self.fc1 = nn.Linear(self.num_states, 32)
        self.fcV1 = nn.Linear(32, 32)
        self.fcA1 = nn.Linear(32, 32)
        self.fcV2 = nn.Linear(32, 1)
        self.fcA2 = nn.Linear(32, self.num_actions)

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

        V = self.fcV2(self.fcV1(x))
        A = self.fcA2(self.fcA1(x))

        averageA = A.mean(1).unsqueeze(1)
        return V.expand(-1, self.num_actions) + (A - averageA.expand(-1, self.num_actions))
        ```
OpenAI gymのCartPole-v0で動かしてみたため、convolutionは使わずすべて全結合層のネットワークになっている。通常のDQNでは上記コードのself.fcAの出力をそのままreturnすることになるが、それに対してVの出力の結果を足し合わせている。

ちなみにpytorchではinput, output共にミニバッチとして処理されるため、expandの第一引数はバッチ数を変化させないことを示す-1。バッチの中身一つで見ればVはスカラー値、Aはaction数分のtensorなので、Vの値をexpandでaction数分のtensorにしている。

これをcartpoleで動かしてみたところ、通常のDQNだと10回連続で成功するまでにかかるepisodeは200程度だったが、dueling networkを入れたところ130程度になった。
動かしたコードの全体はこちら

[https://github.com/y-kamiya/machine-learning-samples/blob/c261e42bc18cb09cb8575396045c9abd49faad9e/python3/reinforcement/cartpole_rainbow.py:title]

## なぜこの計算式でよいのか?
qiitaの記事などで調べたが、以下の式でQ(s,a)を計算する理由がよくわからなかったので論文を読んでみた。

 [tex:\displaystyle Q(s,a) = V(s) + A(s,a) - \frac{1}{|A|}\sum_{a} A(s,a)]

以下はこの答えに当てはまる部分を論文から引用したもの。直訳ではない部分ありますがあしからず。

>Using the definition of advantage, we might be tempted to
construct the aggregating module as follows:

>Q(s, a; θ, α, β) = V (s; θ, β) + A(s, a; θ, α), (7)

単純にこう算出したくなるが、それは以下の理由でよくない
>However, we need to keep in mind that Q(s, a; θ, α, β)
is only a parameterized estimate of the true Q-function.
Moreover, it would be wrong to conclude that V (s; θ, β)
is a good estimator of the state-value function, or likewise
that A(s, a; θ, α) provides a reasonable estimate of the advantage
function.

>Equation (7) is unidentifiable in the sense that given Q
we cannot recover V and A uniquely. To see this, add a
constant to V (s; θ, β) and subtract the same constant from
A(s, a; θ, α). This constant cancels out resulting in the
same Q value. This lack of identifiability is mirrored by
poor practical performance when this equation is used directly.

まず忘れてはならないのはQ, V, Aはどれもある条件における推定値であって真の値ではないということ。
また、Qの値からV, Aの値を一意に決めることはできない。(Vの値からある定数値を引き、かつAの値にその同じ定数値を足せば、同じQの値となる)そのため、これをそのまま使うと計算の効率が悪い。なので

>To address this issue of identifiability, we can force the advantage
function estimator to have zero advantage at the
chosen action. That is, we let the last module of the network
implement the forward mapping

> [tex:\displaystyle Q(s, a; θ, α, β) = V (s; θ, β) + A(s, a; θ, α) − max_{a'∈|A|} A(s, a'; θ, α)]. (8)

これを解決するために特定のactionをとった際にadvantage Aが0となるようにした。

>Now, for

> [tex:\displaystyle a* = arg max_{a'∈A} Q(s, a'; θ, α, β)] 

> =  [tex:\displaystyle  arg max_{a'∈A} A(s, a'; θ, α)]

>, we obtain Q(s, a∗; θ, α, β) =V (s; θ, β).

>Hence, the stream V (s; θ, β) provides an estimate
of the value function, while the other stream produces
an estimate of the advantage function.

特定のactionとはそのstateで取りうるactionの中でQを最大にするもので、このときQ(s,a) = V(s)となる。したがってVを算出した全結合層の出力はvalue functionの推定値と考えることができ、他方をadvantage functionの推定値と考えることができる。


不勉強なため上記の”したがって”が直感的には理解できなかったが、Qの最大値を引いた値を出力として扱うことによって、lossをネットワークにフィードバックする際の更新が効率よくできるようになる、ということだと思われる。

>An alternative module replaces the max operator with an
average:

> [tex:\displaystyle Q(s, a; θ, α, β) = V (s; θ, β) +A(s, a; θ, α) −
\frac{1}{|A|} \sum_{a'} A(s, a'; θ, α)]
. (9)

>On the one hand this loses the original semantics of V and
A because they are now off-target by a constant, but on
the other hand it increases the stability of the optimization:
with (9) the advantages only need to change as fast as the
mean, instead of having to compensate any change to the
optimal action’s advantage in (8).

また、Qの最大値を使うかわりにQの平均値を使うこともできる。
この場合、VとAの元々の意味合いは失われてしまうが、その分計算の安定性が向上する。lossをネットワークの最適化に適用する際、Qの最大値を使う場合に比べて平均値を使う場合の方が変化の割合が安定する。



以上のことより、ネットワークの重み更新を効率よくするために論文のような形の式で出力を扱っていることがわかった。

Google Colab上でgym-retroのソニックを学習

以前迷路の学習を方策勾配法でやってみて、それをこちらにまとめた

方策勾配法とニューラルネットワークで迷路を学習 - MEMOcho-

これと同じ方法をgym-retroに適用してソニックの学習を試してみた。ちなみに先に結果を書いておくと、スコアをちゃんと取れるようになるほどちゃんと学習させることはできていない。ただ、右に行くことに報酬を与えることにより、その行動ばかりとるようにはなるので学習自体はできている模様。

ソニックを実行するためにはromのデータを入手する必要があるが、その方法についてはこちらがわかりやすい

OpenAI Retro Contestの「Gym Retro Integration」でソニック・ザ・ヘッジホッグをプレイする - おおかみ山

今回動かしてみたコードの全体はこちら

machine-learning-samples/sonic_policy_simple.py at master · y-kamiya/machine-learning-samples · GitHub

env.step()が返すdoneやrewardの値はこちらのようなjsonをscenario.jsonとしてrom.mdと同じディレクトリに置くことで設定できる

{
  "done": {
    "variables": {
      "lives": {
        "op": "equal",
        "reference": 2
      }
    }
  },
  "reward": {
    "variables": {
      "x": {
        "reward": 0.001
      },
      "score": {
        "reward": 1.0
      }
    }
  }
}

ライフは1減った時点でdoneとし、x方向に移動した場合とscoreを得た時点でrewardが入るような設定になっている。

また、rewardについては更にgymやgym-retroのbaselinesに含まれるこちらのwrapperを利用して小さな値になるようにしてある。

class RewardScaler(gym.RewardWrapper):
    """
    Bring rewards to a reasonable scale for PPO.
    This is incredibly important and effects performance
    drastically.
    """
    def reward(self, reward):
        return reward * REWARD_RATE

他にもいくつかwrapperを使っているがそれらはすべて以下からloadしたりコピーして持ってきて使っている

retro-baselines/sonic_util.py at master · openai/retro-baselines · GitHub

baselines/atari_wrappers.py at master · openai/baselines · GitHub

ちなみに全体的に参考にしたのはこちらのブログと論文

OpenAI Retro Contest Day 3 – Tristan Sokol – Medium

https://s3-us-west-2.amazonaws.com/openai-assets/research-covers/retro-contest/gotta_learn_fast_report.pdf

ただし、モデルのハイパーパラメータは上記に合わせたわけでなく適当なまま。

とりあえず動くようになった段階で、1 episode = 2500 stepssで400 episodes分学習させてみたところ丸一日くらいかかった。(Core i7, GPUなし)

これでは試行錯誤しづらいため、gpuを使える環境としてGoogle Colabolatoryを利用してみた。使うのは初めてだったが簡単に導入できた。google colabについてはググればたくさん記事が出てくるので割愛。

以下のようなスクリプトを作って毎回のインスタンス起動時に楽できるようにしておいた。

machine-learning-samples/setup_retro_google_colab.sh at master · y-kamiya/machine-learning-samples · GitHub

google colabのインスタンスにつなげたら以下のようにすればpytorch+gym-retroの環境構築し、学習を実行できる

# 最初にrom.mdをupload
from google.colab import files
files.upload()

!git clone https://github.com/y-kamiya/machine-learning-samples.git
!chmod +x machine-learning-samples/python3/reinforcement/sonic/setup_retro_google_colab.sh
!./machine-learning-samples/python3/reinforcement/sonic/setup_retro_google_colab.sh

%cd machine-learning-samples/python3/reinforcement/sonic
!python sonic_policy_simple.py  --episodes 400 --steps 2500 > log

files.download("log")
files.download("model_state_sonic.dat")

これで実行したところ丸一日程度かかってたものが、2時間程度で終わるようになった。

参考

OpenAI Retro Contest Day 3 – Tristan Sokol – Medium

https://s3-us-west-2.amazonaws.com/openai-assets/research-covers/retro-contest/gotta_learn_fast_report.pdf

retro-baselines/sonic_util.py at master · openai/retro-baselines · GitHub

baselines/atari_wrappers.py at master · openai/baselines · GitHub

深層強化学習:ピクセルから『ポン』 – 前編 | POSTD