老彭札记
首发于老彭札记
CS294强化学习课程笔记(二):Policy Gradient

CS294强化学习课程笔记(二):Policy Gradient

前言

现如今流行的强化学习算法如 TRPO、DDPG、PPO 等无不是以 Policy Gradient 算法为基础。本文从基本公式出发,逐步推演出 Policy Gradient 的基本数学形式和各种变体,并讨论了高方差问题和解决方法,最后给出了 Policy Gradient 算法的 PyTorch 简单实现。

注意:本文约七千字,含有大量数学公式,建议在电脑端阅读。本文为了保证易读性和逻辑的连续性,可能会显得非常啰嗦。本文有关代码:PengZhenghao/CS294-Homework


系列文章

彭正皓:CS294强化学习课程笔记(一):Imitation Learning

彭正皓:CS294强化学习课程笔记(二):Policy Gradient

彭正皓:CS294强化学习课程笔记(三):Actor-Critic




目录

  1. 本节任务
  2. 理论背景
    1. 目标是什么?
    2. Policy Gradient
    3. REINFORCE 算法
    4. 降低 policy gradient 的方差
    5. Value Function、Q Function 和 Advantage
    6. 拟合价值函数和写出 Loss
    7. 增加 Discount Factor (gamma)
    8. 番外:关于连续动作空间的一些问题
  3. 实验结果
  4. 代码实现
  5. 附录一:为什么引入常数 baseline 可以无偏地降低 policy gradient 的方差?
  6. 附录二:为什么可以用 Value Function 充当 baseline?
  7. 附录三:reward to go 为什么可以?


本节任务

  1. 介绍 Policy Gradient 的基本思路
  2. 推倒 Policy Gradient 的数学形式及变形(引入 Reward to go 和 Baseline)
  3. 介绍 REINFORCE 算法
  4. 讨论 PG 算法的方差问题以及对策(引入 Value Function)
  5. 实现 PG 算法并讨论实验结果

理论背景

前文定义了一系列概念和数学符号,比如动作 a_t ,状态 s_t ,观察 o_t ,奖励 r(s_t, a_t) ,策略 Policy: \pi(a_t|o_t) ,状态转移概率 p(s_{t+1}|s_t, a_t) 等等。注意,在本文中,我们只考虑“agent 全观察”的情况,即 o_t = s_t, \pi(a_t|o_t) = \pi(a_t|s_t)

后文中,我将简单地定义 Reinforcement Learning 的目标。然后一步一步由简单到复杂,推导出 Policy Gradient 的数学表达式。

一、目标是什么?

为了方便起见,定义 轨迹(Trajectory)\tau=\{o_1, a_1, ..., o_N, a_N\} ,表示一个 episode 中的所有 observation 和 action 所组成的集合。一条轨迹表示着一次“运行”、一次从开始到死去的“探索”、一个 episode 中所有时刻的观察和动作。

前文提到了,强化学习的基本任务就是“寻找到一种 agent 的行为方式(Policy),使得其获得的奖励最大化”。用数学的方式写出来,就是:

\theta^* = \mathop{argmax}_\theta \mathop{E}_{\tau\sim p_\theta(\tau)} [\sum_t r(s_t, a_t)]~~~~(1)

式子中的 \tau\sim \pi_{\theta}(\tau) 是一种“简便说法”。其具体含义如下,后文不再赘述。

\tau = \{s_1, a_1, ..., s_T, a_T\}

\text{subject to}~s_1\sim p(s_1),  a_1\sim \pi_\theta(a_1|s_1), s_2\sim p(s_2|s_1, a_1),...

这么理解(1)式:我们希望找到一个最好的 Policy(或者说一组最好的参数 \theta^* ,用这组参数就可以得到一个 Policy 函数,即输入为观察,输出为动作的函数),使得在这个环境所有可能出现的情况中(数学期望符号),我们的 agent 从头走到尾所收获的奖励之和(求和号)最大。式子中 argmax 右边的部分有个专有名称:Expected Reward

这个式子没有考虑 discount factor,但在后文有一节专门讨论这个。

一个显然的问题就出现了。如何才能实现这个轻描淡写的“argmax”呢?一个简单的想法就是,如果我们能够写出一个梯度的表达式,使得: \theta \gets \theta + \alpha \cdot gradient 之后, \theta 可以趋近于 \theta^* ,这样就可以用随机梯度下降算法(SGD)来进行优化和求解了。 那直接写梯度行不行呢?

gradient = \nabla_\theta   \mathop{E}_{\tau\sim p_\theta(\tau)} [\sum_t r(s_t, a_t)]

上式显然是不行的,因为它不能显式地写出一个数学表达式来。写出这个的表达式的难处在于:

  1. 参数是蕴含在每一时刻的策略 \pi_{\theta}(a_t|s_t) 中的
  2. 策略会影响 a_t概率分布,而不是直接的影响
  3. a_t 虽然具有概率分布,但是为了收获奖励,在实际环境中必须做出一个确定性的选择才行
  4. 而奖励本身与参数毫无关系,奖励对参数求导为零。

那么前辈们就绞尽脑汁试图找到一个写出这个gradient的方法来,实际上就是把上面的这个数学期望符号变成一些可以求导的关于参数的函数的方法。于是,Policy Gradient (PG)算法横空出世了。


二、Policy Gradient

PG 算法的核心任务,就是一句话:通过无偏采样的方式,得到目标函数中的数学期望,并将原来关于待解参数的不可导的目标函数变成可导的,从而可以使用 SGD 很方便的求解。

这里的目标函数指的就是如下面出现的(2)式这样的你希望达成的目标。因此,你会看到很多别的领域的文章(我曾经就设想过用 PG 来解 Nonrigid Registration 问题…不过不了了之了),当遇到了不可导的目标函数的时候,有时候会用 PG 来得到导数。

首先,定义函数 J(\theta) 为 Excepted Reward,那么下式就代表着我们希望最大化的那个目标函数。

J(\theta) = \mathop{E}_{\tau\sim p_\theta(\tau)} [\sum_t r(s_t, a_t)]  = \int p_\theta(\tau) r(\tau) d\tau~~~~(2)

第三项为数学期望展开,其中 p(\cdot), r(\cdot) 都是“简便说法”,即 p_\theta(\tau) 为轨迹出现的概率; r(\tau) = \sum_t r(s_t, a_t) 。接下来,我们要把这个式子的梯度的具体形式写出来,然后看看有什么可以操作的空间,从而可以得到关于参数的梯度的数学表达式。

对(2)式关于参数求导,有:

\nabla_\theta J(\theta) =\nabla_\theta \int  p_\theta(\tau) r(\tau) d\tau  = \int r(\tau) \nabla_\theta p_\theta(\tau) d\tau~~~~(3)

式子中奖励函数是跟参数无关的。因此只要考虑p的导数。这里,前辈们用了一个骚操作:

\nabla_\theta p_\theta(\tau) = p_\theta (\tau) \cfrac{\nabla_\theta  p_\theta(\tau)}{ p_\theta(\tau)} =  p_\theta(\tau) \nabla_\theta \log  p_\theta(\tau)~~~~(4)

为什么要引入log呢?因为,你会发现(再重复一遍,这里可以假设 agent 可以观察到全部环境,所以 o_t=s_t ):

p_\theta(\tau) = p_\theta(s_1, a_1, ...) = p(s_1) \prod_t \pi_\theta (a_t|s_t) p(s_{t+1}|s_t, a_t)~~~~(5)

上面这个式子表明,一条轨迹出现的概率,和三个因素有关:初始状态、状态转移概率以及策略网络决定的动作概率。因此,引入了 log 函数后就可以把上面的连乘变成连加了,这大大方便了后续的处理。

这里有一个隐形的假设,就是 agent 从开始到结束的这个过程是马尔可夫过程(Markov Process)。简要地说,它保证了一件事情,即下一时刻的状态 s_{t+1} 仅仅与上一时刻的状态 s_t 有关,而与再之前的无关。正是有了这个假设的存在,我们才可以写出状态转移概率 p(s_{t+1}|s_t, a_t) ,否则就要写成 p(s_{t+1}|s_1,a_1,...,s_t,a_t) 这样,式子(5)就不明确了。

现在可以把 p_\theta(\tau) 即第(5)式简单地展开一下,

\log p_\theta (\tau) = \log p(s_1) + \sum_t \log \pi_\theta(a_t|s_t) + \sum_t \log p(s_{t+1}|s_t, a_t)~~~~(6)

把(6)代入(4)的话,会发现(6)的第一项和第三项都跟参数无关,求导后为零。写出:

\nabla_\theta p_\theta(\tau) =p_\theta(\tau) (\nabla_\theta \sum_t \log \pi_\theta(a_t|s_t) ) =  p_\theta(\tau) ( \sum_t  \nabla_\theta \log \pi_\theta(a_t|s_t) ) ~~~~(7)

现在把(7)代入原始的(3)式,并把奖励函数展开来写,有:

\nabla_\theta J(\theta) = \int \nabla_\theta p_\theta(\tau) r(\tau) d\tau =  \int p_\theta(\tau) ( \sum_t  \nabla_\theta \log \pi_\theta(a_t|s_t) ) (\sum_t r(s_t, a_t) )d\tau ~~~~(8)

写成数学期望的形式:

\nabla_\theta J(\theta) = \mathop{E}_{\tau\sim p_\theta(\tau)} [( \sum_t  \nabla_\theta \log \pi_\theta(a_t|s_t)) (\sum_t r(s_t, a_t)) ]~~~(9)

上面这个式子是最原始的 policy gradient (这个词在这里表示的就是“策略梯度”,而不是一种算法)。


三、REINFORCE 算法

如何实际应用(9)式呢?把数学期望替换成若干次采样的平均值就行了。

\nabla_\theta J(\theta) =\cfrac{1}{N}\sum_{i=1}^N [( \sum_t  \nabla_\theta \log \pi_\theta(a_{i,t}|s_{i,t}) (\sum_t r(s_{i,t}, a_{i,t}) ]~~~~(10)

REINFORCE 算法的基本流程就是:

  1. 执行policy,得到N个样本。
  2. 计算(10)式。
  3. 更新参数: \theta \gets \theta + \alpha \nabla_\theta J(\theta)
  4. 重复步骤1。


事实上,如果把式子(10)最右边的关于奖励的求和号(也就是 Expected Reward)挡住,你会发现这个式子和最大似然算法的梯度的表达式是一样的。啥意思呢?就是说我们发现式子(10)冥冥之中就跟 supervised learning 的梯度有相似之处。那么这个 gradient 到底起到了什么作用?答案是让现在的网络学着去输出“Expected Reward 最大”的那组动作。回忆一下,在分类任务中,loss 函数是不是跟式子(10)很像?唯一不同的是分类任务用一个 one-hot 向量代替了上面的这个 Expected Reward。

所以,我说 PG 算法的实质是通过采样得到的最好的动作作为 label,来做 Policy Network 的 supervised learning。

抱着这样的理解,“哦,朴素的 PG 算法和有监督学习差不多是一码事”,再去考虑一个情况:如果有多个完美的policy,使得 Expected Reward 最大呢?这样的情况在平时的回归任务中基本是不可能发生的:你不能指望对于同一个输入,神经网络会给出不同的输出。但是在强化学习任务中,这种情况的确可能出现,因为总的奖励并不仅仅由此刻的 action 所决定,还跟未来的发展趋势有关。这种情况,会导致高方差问题。这里的方差,指的是:policy gradient 的方差,也就是(10)式方括号那部分的方差。

为什么出现了高方差?因为采样的轨迹千差万别,而且可能不同的 action 会带来一样的 Expected Reward。如果在分类任务中出现一个输入可以分为多个类的情况,梯度就会乱掉,因为网络不知道应该最大化哪个类别的输出概率。

高方差会带来什么问题?方差很高,就说明梯度很不稳定,式子(10)不能够妥善地近似式子(9)。式子(9)按理说应该是一个定值,我们只是依靠式子(10)去近似它而已。高方差问题就是人们常说的“强化学习学习起来很不稳定”,“难以收敛”,“收敛很慢”等等的问题的元凶。

如何直观的理解 Policy Gradeint 的方差?简单的说,如果把 agent 想象成一个高三学生的话,方差就是他成绩的稳定性。一个精神有问题的考生,有时表现好有时表现不好,老师对他的指导(gradient)只能有时多有时少,自然方差就大了。一个精神状态稳定的学生,虽然 ta 可能并不是学霸,但是 ta 一直在稳步的学习,老师给他的指导也非常稳定,方差自然就小了。方差小是好事,因为这样 agent 的“进步”就会很稳定。方差大了的话,agent 受到乱七八糟的“指导”,很容易就会疯掉了。


四、降低 policy gradient 的方差

把式子(3)和(10)复制过来:

\nabla_\theta J(\theta) =\nabla_\theta \int  p_\theta(\tau) r(\tau) d\tau  = \int (\nabla_\theta p_\theta(\tau) )r(\tau) d\tau~~~~(3)

\nabla_\theta J(\theta) =\cfrac{1}{N}\sum_{i=1}^N [( \sum_{t=1}^T  \nabla_\theta \log \pi_\theta(a_{i,t}|s_{i,t}) (\sum_{t=1}^T r(s_{i,t}, a_{i,t}) ]~~~~(10)

回看式(10)后面的两个求和号的乘积: ( \sum_t  \nabla_\theta\log \pi_\theta(a_{i,t}|s_{i,t}) (\sum_t r(s_{i,t}, a_{i,t}) ,它们出现的源头来自于式(3)中 (\nabla_\theta p_\theta(\tau) )r(\tau) 。但是在式(10)中,讨论的对象却从式子(3)的“一个轨迹的总体特性”变成了“一个 episode 中的每一 step 的特性之和”。于是这两个求和号的乘积会导致一些奇妙的组合:

t-1 时刻的奖励 r(s_{i, t-1}, a_{i, t-1}) 竟然会和未来的 t 时刻的动作的梯度相乘了!直观地,未来的动作不应当为之前的奖励负责。因此修改(10)式,使得 t 时刻的动作只会和 t 时刻以及之后所有的奖励发生关系,而之前的奖励与它无关。

\nabla_\theta J(\theta) =\cfrac{1}{N}\sum_{i=1}^N [ \sum_{t=1}^T [ \nabla_\theta\log  \pi_\theta(a_{i,t}|s_{i,t}) \sum_{t'=t}^T r(s_{i,t'}, a_{i,t'}) ]]~~~~(11)

这个从 t 时刻开始计算的 reward,有个名字,叫做 reward to go。reward to go 把不存在的因果关系给剔除了。

紧接着,前辈们为式子(3)增加了一点骚操作:让 Expected Reward 减去一个基准,称为 Baseline。它可以是常数,也可以是一个关于当前状态的函数。(请原谅我滥用括号,因为在以前读文章的时候因为作者省略括号而造成的误解让我非常的抓狂)

\nabla_\theta J(\theta) = \int (r(\tau) - b) \nabla_\theta p_\theta(\tau) d\tau~~~~(12)

\nabla_\theta J(\theta) =\cfrac{1}{N}\sum_{i=1}^N [ \sum_{t=1}^T [ \nabla_\theta\log  \pi_\theta(a_{i,t}|s_{i,t})( (\sum_{t'=t}^T r(s_{i,t'}, a_{i,t'}))-b(s_{t})) ]]~~~~(\text{12b})

可以证明(参见本文附录一,在代码实现的后面),为 Policy Gradient 增加一个 Baseline 不会改变“用式(10)近似式(9)”的无偏性。换句话说,加了 baseline 的式子(10)可以无偏地(unbiased)近似加了 baseline 的式子(9)。这一点在理论上是很重要的,不然如果加了 baseline 就使得这个似然估计的过程失效了就得不偿失了。

而且,经过分析可以证明,通过巧妙的选取 baseline,可以降低 PG 的方差。比如令 b = \frac{1}{N}\sum_{i=1}^N r(\tau) ,这个式子是奖励的平均值(即 Expected Reward)。大家可能在别的教程中看过,对 baseline 的直观理解就是,当奖励大于平均值,说明现在的这条轨迹(这段经历)有“值得学习的地方”,因此 PG 是正的。反之亦然。


五、Value Function、Q Function 和 Advantage

本节要引入几个概念,之后就可看到 Policy Gradient 真正的魅力了!

引入价值函数(Value Function)的概念。Value Function 的定义是:

V(s_t) \approx \sum_{t'=t}^T \mathop{E}_{\pi_\theta} [r(s_{t'}, a_{t'})|s_t]~~~~(13)

价值函数只跟当前的状态、Policy Network 的参数有关,而与具体选择了哪个动作无关。直观的理解,价值函数就像是考生的老师,看到这张卷子,就能根据对你的了解知道你会考多少分,而不必猜出你到底是怎么答题的。

价值函数只跟状态有关而与具体动作无关,可以证明用它充当 baseline 不会带来问题(无偏性),而且可以降低方差(见附录二)。其引入的一个初衷是因为 PG 方差与状态有关。在同一个状态下,不同动作所带来的 PG 方差可能很大也可能很小。比如你在走钢丝,歪了就死,正了就活,但你走在平地歪了和正了都没什么关系。

把价值函数当成 baseline,PG 就可以写成:

\nabla_\theta J(\theta) =\cfrac{1}{N}\sum_{i=1}^N \{ \sum_{t=1}^T \{ \nabla_\theta\log  \pi_\theta(a_{i,t}|s_{i,t}) [(\sum_{t'=t}^T r(s_{i,t'}, a_{i,t'}))-V(s_t) \}\}~~~~(14)

我们为价值函数左边的那块取个名字:Q 函数(Q function)。函数上面的 \pi 没别的意思,只是说明它和 policy 有关系。

Q^{\pi}(s_t, a_t) = \mathop{E}[\sum_{t'=t}^T r(s_{t'}, a_{t'})]~~~~(15)

我们发现它和价值函数本身长得很像。唯一的不同之处在于,它的自变量包含了当前的动作,换言之,如果把价值函数理解成“在这个境地下,agent 应该会收获什么结局”,那么 Q 函数就是“在这个境地下,agent 做出了这个选择,它应该会收获什么结局”。前者是对局势的可能后果的估计,而后者考虑了具体的动作。

我们为 Q 函数和 Value function 的差值起个名字,叫 Advantage(优势函数)

A^{\pi}(s_t, a_t) = Q^{\pi}(s_t, a_t) - V^{\pi}(s_t)~~~~(16)

这样,PG 可写成

\nabla_\theta J(\theta) =\cfrac{1}{N}\sum_{i=1}^N \{ \sum_{t=1}^T \{ \nabla_\theta\log  \pi_\theta(a_{i,t}|s_{i,t}) A^{\pi}(s_{i,t}, a_{i,t}) \}\}~~~~(17)

如果 agent 不甘平庸,在此时做出了一个出乎意料的好决定,那么他在这个情景下的这个动作的 advantage 就会非常大,我们希望在它的“来生”,在同样的这个情景下,要更倾向于做出刚才那个好动作。

现在,我们在工程的角度来回看(14)式子。计算 Q 函数的估计值,可以用实验数据直接相加得到,虽然这样会因为用数据代替数学期望而引入误差。之后,你会发现站在时刻 t 去预判“未来可期”的奖励,也就是计算 V(s_t) 这个事情有点复杂。直接把采样得到的数据加起来是不合理的,因为这只是浩如烟海的无数可能性中的一条轨迹而已。因此,我们需要采取某种方法(神经网络喇,说这么玄乎)去拟合价值函数。


六、拟合价值函数和写出 Loss

现在我们看看手里的武器,我们知道了式子(17),我们知道了 REINFORCE 算法,我们还知道了我们需要拟合 Value Function。那么现在的问题就是,

  1. 如何拟合 Value Function?
  2. 能不能写出一个 Pseudo Loss,让它的梯度正好等于 Policy Gradient?这样实现起来会比较方便。


价值函数长这样:

V(s_t) \approx \sum_{t'=t}^T \mathop{E}_{\pi_\theta} [r(s_{t'}, a_{t'})|s_t]~~~~(13)

所以如果用神经网络来拟合它,直观的想法就是建立数据集 \{s_{i,t}, y = \sum_{t'=t}^T r(s_{i,t'}, a_{i,t'})\}_i ,然后用回归误差: L(\phi) = \frac{1}{2} \sum_i ||V_\phi(s_i) - y||^2 来训练。这个方法就是后面的代码所使用的训练方法。

在什么时候 Update 价值拟合网络呢?答案是和 Policy 网络一起更新。因为按照现在的做法,更新价值网络所需要的数据仅仅是一个 s_t 与对应的 Q(s_t, a_t) 值。而 Policy 网络更新需要的是一个 s_t 与对应的 a_t,Q(s_t,a_t), V(s_t) 。Value 网络和 Policy 网络一起更新没有问题。

在下篇文章中,我们会发现,这种拟合方法非常依赖于采样,因为它考虑了从 t 时刻往后的所有可能性,而采样得到的数据只是其中的一条轨迹而已。如果采样数目过少,得到的 target 就不能代表真正的价值函数,但本文先考虑这种简单的 target。

现在想写出一个伪 Loss 函数,让它的梯度等于 Policy Gradient,这样就可以使用 DL 框架的自动求导功能:

\nabla_\theta J(\theta) =\cfrac{1}{N}\sum_{i=1}^N \{ \sum_{t=1}^T \{ \nabla_\theta\log  \pi_\theta(a_{i,t}|s_{i,t}) [(\sum_{t'=t}^T r(s_{i,t'}, a_{i,t'}))-V(s_t) \}\}~~~~(14)

前面提到了,这个式子和极大似然法的 Loss 长得很像,只不过有后面的 advantage 做了一点加权。因此,我们轻易地写出:

L(\theta) =\cfrac{1}{N}\sum_{i=1}^N \{ \sum_{t=1}^T \{ \log  \pi_\theta(a_{i,t}|s_{i,t}) [(\sum_{t'=t}^T r(s_{i,t'}, a_{i,t'}))-V(s_t) \}\}~~~~(18)

式子中的价值函数,通过调用价值函数拟合神经网络可以得到。


七、增加 Discount Factor (gamma)

回到最开始的时候,如果我们在计算 Expected Reward 的时候,不仅仅是把从开始到结束的所有 reward 相加,而是让他们有权重的相加,让最开始收获的奖励有最大的权重,越往后面权重越小,这样的话我们就可以鼓励 agent 尽快完成他的任务,尽可能的在早期就收获更多的 reward。

\theta^* = \mathop{argmax}_\theta \mathop{E}_{\tau\sim p_\theta(\tau)} [\sum_t \gamma^t r(s_t, a_t)]~~~~(19)

上面的参数 \gamma 是一个不大于1的正数,称之为 discount factor。这么做有什么好处呢?

  1. 鼓励 agent 尽快完成任务
  2. 保证了 Q 函数的收敛性,让它不会无穷大
  3. 让“近期的奖励”有更大的权重,就意味着近期的数据,比如 action 和 state,对 Policy Gradient 有更大的“贡献”,因为 PG 是用蒙特卡洛采样计算出来的,如果考虑的范围越短,数据的可靠性就越高。
  4. 作为 Baseline 的价值函数是拟合出来的,换言之,也是依靠蒙特卡洛采样计算出来的。如果它考虑的范围越近,拟合的精确度自然越高。


现在把上文的一些重要式子修正一下。Q Function(15,19)写成:

Q^{\pi}(s_t, a_t) = \mathop{E}[\sum_{t'=t}^T\gamma^{t'-t} r(s_{i,t'}, a_{i,t'})]~~~~(20)

Value Function(14)写成:

V(s_t) \approx \sum_{t'=t}^T \mathop{E}_{\pi_\theta} [\gamma^{t-t'}r(s_{t'}, a_{t'})|s_t]~~~~(21)

把(14)式子进一步完善,得到 Policy Gradient 的最终公式:

\nabla_\theta J(\theta) =\cfrac{1}{N}\sum_{i=1}^N \{ \sum_{t=1}^T \{ \nabla_\theta\log  \pi_\theta(a_{i,t}|s_{i,t}) [(\sum_{t'=t}^T \gamma^{t-t'}r(s_{i,t'}, a_{i,t'}))-V(s_t) \}\}~~~~(22)

Policy Loss:

L(\theta) =\cfrac{1}{N}\sum_{i=1}^N \{ \sum_{t=1}^T \{ \log  \pi_\theta(a_{i,t}|s_{i,t}) [(\sum_{t'=t}^T  \gamma^{t-t'} r(s_{i,t'}, a_{i,t'}))-V(s_t) \}\}~~~~(23)

完事儿了。


八、番外:关于连续动作空间的一些问题

对于离散的动作空间,神经网络用 Softmax 作为输出层。对于连续的动作空间,神经网络的输出层有着不同的处理。

对于连续的动作空间而言,理论界的通常做法是:假设最终 agent 执行的动作,是从一个(多元)高斯分布中采样而来的。这个高斯分布的均值,由神经网络或其他什么函数的输出值决定。在深度强化学习内,这个高斯分布的方差,是由几个可学习的变量来决定的,这个变量不是关于输入的函数,而仅仅是一个可以学的参数而已。

为什么要这么做呢?

  1. 如果你用神经网络的输出作为动作,这个过程没有任何反馈可言,你只知道“哦,我做了这个动作,得到了这个奖励,然后呢?我该朝哪个方向改进?”
  2. 在理论上没办法设计优化函数。
  3. 你无法得到一个关于 action 的概率分布: \pi(a_t|s_t)


但是如果你采用了高斯分布,就可以解决上述问题:你可以写出给定均值和方差的情况下,选取某个动作的概率,并因此写出关于均值和方差的梯度,最后你可以把关于均值的梯度传递回神经网络中来更新网络参数。

在这种情况下的 Loss 函数与(23)相同,只不过在 log probability 的地方要代入高斯分布的概率。取对数后是一个平方的形式(我也不知道具体长啥样,因为没见过有人使用 PG 来做连续控制问题)。不过一般对于连续控制问题,人们更喜欢使用 PPO、DDPG、TRPO 等在 PG 基础上发展起来的比较新潮的方法。


实验结果

一、比较 batch size 大小,有无 advantage normalization 和 reward to go 的影响

CartPole-v0,小batch size(1000)的比较。绿表示使用了reward to go、不使用advantage normalization,红色使用了reward to go和advantage normalization。蓝色两者都不用。横轴,训练进程;纵轴,平均总奖励。

上图是小 batch 的结果。可以看出,使用了 reward to go 可以增加学习的速度。相对来说,使用了 advantage normalization 训练过程会更加稳定,在后期可以维持“成果”。

大batch size的结果(bs=5000)。rtg:reward to go。dna:don't normalize advantage。na:normalize advantage。

可以看到,reward to go 确实可以增加学习的进程。advantage normalization 的作用不明显。但是 batch size 的增加可以大大加速学习的进程,且减少了后期的不稳定性。

二、batch size 和 learning rate 的相互关系

InvertedPendulum-v2。唯二的两个变量是batch size和learning rate。应作业的要求采取了粗粒度的 grid search。

可以看出,最优秀的两组 {batch size=1000, lr=0.01} 和 {batch size=5000, lr=0.01}。我们发现超参数的选取对表现起到了决定性的作用,失之毫厘,谬之千里(对与 learning rate 而言)。

三、复杂环境,大 Batch Size,不同随机种子

下图展示了在 LunarLanderContinous-v2 中使用大 Batch Size (bs=10000)的实验结果。可以看到,随机种子对结果的影响不大。第20轮平均回报的下降是由环境的特性(即奖励函数的结构)导致的。


代码实现

原始课程代码由于是 TensorFlow 的关系显得纷繁复杂,我用 PyTorch 实现算法,尽可能的精简了代码。重要代码都只在一个文件里(GitHub 地址)。

首先看下代码结构:

def main: 主程序开启多个进程没什么好讲的
def train_PG: 单进程内跑的函数没什么好讲的

class Net: 基本款神经网络
class CategoricalPolicy: 离散动作空间用的策略网络
class GaussianPolicy: 连续动作空间用的策略网络

class Agent:
    def sample_trajectories: 得到一组轨迹
    def sample_trajectory: 从头开始让agent和环境交互得到一条轨迹
    def sum_of_rewards: 计算Q函数在这里实现了reward to go
    def compute_advantage: 利用式子(28)即价值函数的迭代性计算advantage
    def estimate_return: 包装了一下上面两个函数
    def update_parameters: 更新参数


首先定义网络结构,借鉴了 ShangtongZhang 的 DeepRL Repo

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

class Net(nn.Module):
    def __init__(self, obs_dim, act_dim):
        super(Net, self).__init__()
        self.fc0 = nn.Linear(obs_dim, 128)
        self.fc1 = nn.Linear(128, act_dim)

    def forward(self, x):
        x = x.type_as(self.fc0.bias)
        x = F.relu(self.fc0(x))
        x = self.fc1(x)
        return x

class GaussianPolicy(nn.Module): # 连续动作空间
    def __init__(self, obs_dim, act_dim):
        super(GaussianPolicy, self).__init__()
        self.mean = Net(obs_dim, act_dim)
        self.std = nn.Parameter(torch.ones(1, act_dim)) # 看啊,可学习变量!

    def forward(self, obs):
        mean = torch.tanh(self.mean(obs)) # tanh让均值归一化,惯用操作
        dist = torch.distributions.Normal(mean, self.std)
        action = dist.sample()
        log_prob = dist.log_prob(action).sum(dim=1, keepdim=True)
        return action, log_prob
        # 输出log probability是标准操作,方便后续写loss

class CategoricalPolicy(nn.Module): # 离散动作空间
    def __init__(self, obs_dim, act_dim):
        super(CategoricalPolicy, self).__init__()
        self.network = Net(obs_dim, act_dim)

    def forward(self, obs):
        logit = self.network(obs)
        dist = torch.distributions.Categorical(logits=logit)
        action = dist.sample()
        log_prob = dist.log_prob(action).unsqueeze(-1)
        return action, log_prob


Agent 的主体部分。

def normalize(array):  # 归一化函数,1e-7是为了避免除零。
    return (array - array.mean()) / (array.std() + 1e-7)

class Agent(object):
    def __init__(self, args):
        ...

        # 定义policy network
        self.policy = None
            self.policy = CategoricalPolicy(self.ob_dim, self.ac_dim)
        else:
            self.policy = GaussianPolicy(self.ob_dim, self.ac_dim)

        # 定义optimizer,看啊,PyTorch比TF方便多了。
        self.optimizer = torch.optim.Adam(self.policy.parameters(), lr=self.learning_rate)
        if self.nn_baseline:
            self.baseline_prediction = Net(self.ob_dim, 1)
            self.baseline_loss = nn.MSELoss() # 拟合价值函数的网络的loss就是简单的L2范数
            self.baseline_optimizer = torch.optim.Adam(self.baseline_prediction.parameters(), lr=self.learning_rate)

    def sample_trajectories(self, itr, env):
        # 采样一组轨迹,没啥好讲,调用sample_trajectory罢了。
        # 高级点的玩法应当是多线程采样,不过这个以后再说。
        ...
        while 采样数目不到的时候:
            ob, ac, log_prob, re = self.sample_trajectory(env, animate_this_episode)
            ... # 包装数据
        return np.concatenate(obs), acs, torch.cat(log_probs), res, timesteps_this_batch

    def sample_trajectory(self, env, animate_this_episode):
        # 采样一条轨迹。实际上就是类似于Homework1中的test函数。
        ob = env.reset()
        obs, acs, rewards, log_probs = [], [], [], []
        for steps in count(): # 一个语法糖,用itertools中的count类,等价于死循环和一个自加变量。
            ac, log_prob, _ = self.policy(torch.from_numpy(ob).to(self.device).unsqueeze(0))
            if self.discrete: # PyTorch不同数据格式的转换是有点麻烦的
                ac = ac[0].item()
            else:
                ac = ac[0].cpu().detach().numpy()
            obs.append(ob)
            log_probs.append(log_prob.squeeze(-1))
            acs.append(ac)
            ob, rew, done, _ = env.step(ac) # 与环境互动,传入的是Numpy数组
            rewards.append(rew)
            if done or steps > self.max_path_length:
                break
        return np.array(obs), np.array(acs), torch.cat(log_probs), np.array(rewards),

    def sum_of_rewards(self, re_n):
        # 上面都是一些事务性的代码。现在进入正题。
        q_n = []
        for tau in re_n:
            result = []
            Q = 0
            for r in tau[::-1]: # 这里是利用Q函数的迭代性计算Q。
                Q = r + self.gamma * Q # 等于式子(20)
                result.insert(0, Q) # 如果采用Reward to go,就要倒序地向result中插入Q。
            if not self.reward_to_go: # 如果不采用,就用一条轨迹的总奖励,复制若干次作为结果。
                result = [Q for _ in range(len(tau))]
            q_n.extend(result) # list的extend函数。也可以写成list += [1,2,3]
        return np.array(q_n)

    def compute_advantage(self, ob_no, q_n): # 计算advantage
        q_n = torch.from_numpy(q_n).to(self.device) # 从numpy数组变成tensor
        if self.nn_baseline:
            # 调用价值函数网络,得到预测值。里面一大串是numpy数组转换成tensor。
            b_n = self.baseline_prediction(torch.from_numpy(ob_no).to(self.device))
            # 归一化网络的输出,再让它与这个batch的Q值的分布一样。
            b_n = normalize(b_n) * q_n.std() + q_n.mean()  # match the statistics of Q values
            adv_n = q_n - b_n.type_as(q_n)
        else:
            adv_n = q_n # 不用baseline就直接让advantage等于Q。
        return adv_n

    def estimate_return(self, ob_no, re_n): # 调用了上面两个函数
        q_n = self.sum_of_rewards(re_n)
        adv_n = self.compute_advantage(ob_no, q_n)
        if self.normalize_advantages:
            # 这里实现了advantage normalization
            adv_n = normalize(adv_n)
        return q_n, adv_n

    def update_parameters(self, ob_no, log_prob_na, q_n, adv_n):
        # 看啊,PyTorch比TF方便了多少,简单几句话就写完了训练过程。
        if self.nn_baseline:
            # 得到价值函数拟合网络的输出
            prediction = self.baseline_prediction(torch.from_numpy(ob_no).to(self.device).unsqueeze(0))
            self.baseline_optimizer.zero_grad()
            # 目标函数就是Q函数。并且进行了归一化。(回忆一下上面,让网络输出归一化到Q的分布)
            target = normalize(torch.from_numpy(q_n).to(self.device)).type_as(prediction)
            loss = self.baseline_loss(input=prediction, target=target) # 平方误差
            loss.backward()
            self.baseline_optimizer.step()

        self.optimizer.zero_grad()
        loss = -torch.mean(log_prob_na.type_as(adv_n) * adv_n) # 这个就是Policy Loss啦
        loss.backward()
        self.optimizer.step()


为什么要对价值函数神经网络的输出归一化到 Q 的分布上?然后再在训练此网络的时候,将作为 target 的 Q 归一化到标准正态分布上?

答案就是我们希望网络的输出,从头到尾,从开始训练到训练的后期,都满足标准正态分布的形式。这样,我们就可以避免了作为 Target 的 Q 值,在训练过程中的分布发生变化所带来的问题。也就是说,因为 Policy Network 渐渐的训练而引起的 Q 值的变化,不会影响价值网络的训练。这是一种广为使用的,在有多个网络互相交互的时候,避免互相影响的办法。


附录一:为什么引入常数 baseline 可以无偏地降低 policy gradient 的方差?

能不能引入 baseline,主要取决于两个问题:

  1. 它的引入会不会导致式子(10)对式子(9)的估计失效了,即它的引入是否伤害无偏性?
  2. 它的引入能不能降低方差?


证明思路是:首先证明 baseline 是无偏的。然后得到 PG 的方差的表达式,计算方差关于b的梯度(假定b是一个常数),通过分析梯度可以知道b能否降低方差。事实上,可以证明方差关于b是二次方关系,存在一个 b 使得方差最小。

引入了 baseline 的 policy gradient 可以写成:

\nabla_\theta J(\theta)  = \mathop{E}_{\tau\sim p_\theta(\tau)} [\nabla_\theta \log p_\theta(\tau)(r(\tau) - b) ] ~~~~(A1)

把上式轻轻一拆:

\nabla_\theta J(\theta)  = \mathop{E}_{\tau\sim p_\theta(\tau)}[ \nabla_\theta \log p_\theta(\tau)r(\tau)] - b \mathop{E}_{\tau\sim p_\theta(\tau)}[ \nabla_\theta \log p_\theta(\tau)) ]~~~~(A2)

下面证明第二项等于0。

\mathop{E}_{\tau\sim p_\theta(\tau)}[ \nabla_\theta \log p_\theta(\tau)) ] =  \mathop{E}_{\tau\sim p_\theta(\tau)}[ \cfrac{\nabla_\theta p_\theta(\tau)}{p_\theta(\tau)}  ]=\int \nabla_\theta p_\theta(\tau) d\tau =  \nabla_\theta \int p_\theta(\tau) d\tau = \nabla_\theta 1 = 0~~~~(A3)

反常积分和求导互换的原因是,概率密度函数关于参数是一致收敛的。上面证明了引入 baseline 跟没引入相比,policy gradient 的数学期望根本就没变化,自然就不会影响原来的估计。

现在来计算方差,

Var(x) = E[x^2] - E^2[x]

Var(PG) = \mathop{E}_{\tau\sim p_\theta(\tau)} [(\nabla_\theta \log p_\theta(\tau)(r(\tau) - b) )^2]- \mathop{E^2}_{\tau\sim p_\theta(\tau)} [\nabla_\theta \log p_\theta(\tau)(r(\tau) - b) ] ~~~~(A4)

注意,(A4)出现的关于轨迹 \tau 的函数,如 p(\tau), r(\tau) 等都真的是一个函数,输出的是一个数值,而不是某种奇怪的简写。换言之,在计算方差的时候,没有考虑上文提到的“reward to go”的这种因果关系。原因是如果考虑了根本就写不出方差的具体的简单的形式(是这样的吧?请大家指教)

我们希望求出 Var 关于 b 的梯度。现在先证明(A4)式第二项关于 b 的梯度为0。上面已经证明了引入 baseline 后的数学期望和原来一样,所以(A4)的第二项关于 b 的梯度写成:

\nabla_b(\mathop{E}_{\tau\sim p_\theta(\tau)}[ \nabla_\theta \log p_\theta(\tau)r(\tau)] )^2 = 0~~~~(A5)

因此(A4)现在只用考虑第一项关于b的求导。方便起见,设 g(\tau) = \nabla_\theta \log p_\theta(\tau) ,显然g关于b求导为零。

\cfrac{\partial Var}{\partial b} = \cfrac{\partial}{\partial b}   \mathop{E}_{\tau} [(g^2(r - b) )^2]= \cfrac{\partial}{\partial b} (E[g^2r^2] - 2b E[g^2r]+b^2E[g^2])~~~~(A6)

\cfrac{\partial Var}{\partial b} = -2E[g^2r]+2b E[g^2]~~~~(A7)

可以看到,方差与b成二次方关系。令(A7)式等于零,解出当 b 取下面值的时候,可以使得方差最小。

b = \cfrac{E[g^2r]}{E[g^2]} = \cfrac{E[( \nabla_\theta \log p_\theta(\tau))^2 r(\tau)]}{E[( \nabla_\theta \log p_\theta(\tau))^2]}~~~~(A8)

可以看到这个值是用梯度的大小加权了的 Expected Reward。在实践中,令 b = \frac{1}{N}\sum_{i=1}^N r(\tau) 是个不错的近似。

你可能会发现,本节的讨论都是假设b是一个常数。但是在实际应用中通常使用 Value Function 来当 baseline。它是关于当前状态的函数。这个事情真的可以吗?


附录二:为什么可以用 Value Function 充当 baseline?

首先列出 Policy Gradient。你会看到文章中出现了各种各样的 PG 的表达式,有的是用 trajectory 来简写,有的是逐 step 的完整形式,有的用积分,有的用数学期望,但它们的意思都是一样的。由于引入了逐 step 的 value function,因此现在不得不用最原始的形式:

\nabla_\theta J(\theta) =E [ \sum_{t=1}^T [ \nabla_\theta \log \pi_\theta(a_{t}|s_{t}) [\sum_{t'=t}^T[ r(s_{t'}, a_{t'})]-V(s_{t})] ]]~~~~(A9)

这个式子外面的数学期望,表示着所有可能的情况。仔细想来,其中的随机变量为 a_t, s_t, s_{t'}, a_{t'} 。现在我们试着把这个式子全部写成数学期望的形式:

\nabla_\theta J(\theta) = \sum_{t=1}^T \mathop{E}_{s_t}\mathop{E}_{a_t}  \{ \nabla_\theta \log \pi_\theta(a_{t}|s_{t}) \{\sum_{t'=t}^T  [\mathop{E}_{s_{t'}}\mathop{E}_{a_{t'}} r(s_{t'}, a_{t'})]-V(s_{t}) \}\}~~~~(A10)

现在证明和常数的 baseline 一样,引入价值函数不会影响 PG 的数学期望。把上式拆成两部分,关于 V 的部分是:

\text{2nd term}  = -\sum_{t=1}^T \mathop{E}_{s_t}\mathop{E}_{a_t}  [ \nabla_\theta \log \pi_\theta(a_{t}|s_{t}) V(s_{t}) ]

=  -\sum_{t=1}^T \mathop{E}_{s_t}\{V(s_{t}) \mathop{E}_{a_t}  [ \nabla_\theta \log \pi_\theta(a_{t}|s_{t})]\}=-\sum_{t=1}^T \mathop{E}_{s_t}\{V(s_{t})\nabla_\theta \mathop{E}_{a_t}  [  \log \pi_\theta(a_{t}|s_{t})]\}

=-\sum_{t=1}^T \mathop{E}_{s_t}\{V(s_{t})\nabla_\theta 1\} = 0 ~~~~(A11)

其中数学期望和关于参数求梯度为什么能互换,为什么突然冒出了数字1,请参见附录一公式(A3)。

现在回顾一下 Q 函数和 Value Function 的定义:

Q(s_t, a_t) = \mathop{E}[\sum_{t'=t}^T r(s_{i,t'}, a_{i,t'})]

V(s_t) = \sum_{t'=t}^T \mathop{E}_{a_{t'}\sim \pi_\theta} [r(s_{t'}, a_{t'})|s_t] = \mathop{E}_{a_t\sim \pi_\theta(a_t|s_t)}[Q(s_t,a_t)]

观察式子(A10),发现 Value Function 和它左边的那项 \sum_{t'=t}^T[\mathop{E}_{s{t'}}\mathop{E}_{a{t'}} r(s_{t'}, a_{t'})] ,也就是 Q 函数,几乎一模一样。价值函数是 Q 函数的数学期望。

在计算梯度的时候,价值函数只给出一个数值,而 Q 函数是一个表达式。因此在求梯度的时候将价值函数视为零,而 Q 函数由于自变量中包含动作,因此将会计算梯度。让一个表达式减去自己的数学期望,直观上就是 至于价值函数为什么能够降低方差?我只能说,它足够的接近于式子(A8)。就是这样。

附录三:Reward to go 为什么可以?


把式子(3)、(10)、(11)复制过来:

\nabla_\theta J(\theta) =\nabla_\theta \int  p_\theta(\tau) r(\tau) d\tau  = \int (\nabla_\theta p_\theta(\tau) )r(\tau) d\tau~~~~(3)

\nabla_\theta J(\theta) =\cfrac{1}{N}\sum_{i=1}^N [( \sum_{t=1}^T  \nabla_\theta \log \pi_\theta(a_{i,t}|s_{i,t}) (\sum_{t=1}^T r(s_{i,t}, a_{i,t}) ]~~~~(10)

\nabla_\theta J(\theta) =\cfrac{1}{N}\sum_{i=1}^N [ \sum_{t=1}^T [ \nabla_\theta\log  \pi_\theta(a_{i,t}|s_{i,t}) \sum_{t'=t}^T r(s_{i,t'}, a_{i,t'}) ]]~~~~(11)

这个从 t 时刻开始计算的 reward,有个名字,叫做 reward to go。reward to go 把不存在的因果关系给剔除了。锦瑟(抱歉重名太多at不到你)问到了为什么从式子10可以到式子11,事情是这样的:

式子(10)写成:

E  (\sum_{t=1}^T  \nabla_\theta \log \pi_\theta(a_{i,t}|s_{i,t}) )(\sum_{t=1}^T r(s_{i,t}, a_{i,t}) )

=E  (\sum_{t=1}^T  \nabla_\theta \log \pi_\theta(a_{i,t}|s_{i,t}) )(\sum_{t'=1}^t r(s_{i,t'}, a_{i,t'}) +\sum_{t'=t}^T r(s_{i,t'}, a_{i,t'}))

=E  (\sum_{t=1}^T  \nabla_\theta \log \pi_\theta(a_{i,t}|s_{i,t}) )(\sum_{t'=1}^t r(s_{i,t'}, a_{i,t'})) + E  (\sum_{t=1}^T  \nabla_\theta \log \pi_\theta(a_{i,t}|s_{i,t}) )(\sum_{t'=t}^T r(s_{i,t'}, a_{i,t'}))~~~~(A12)

上式的第二项是我们要的,下面证明第一项为0。

E  (\sum_{t=1}^T  \nabla_\theta \log \pi_\theta(a_{i,t}|s_{i,t}) )(\sum_{t'=1}^t r(s_{i,t'}, a_{i,t'}))

 = E  (\sum_{t=1}^T  r(s_{i,t}, a_{i,t}) )(\sum_{t'=t}^T \nabla_\theta \log \pi_\theta(a_{i,t'}|s_{i,t'}) ) ~~~~(A13)

上面调换了一下两项的位置,接着:

 (A13)=\sum_{t=1}^T \sum_{t'=t}^T  \mathop{E}_{s_t, a_t, s_{t'}, a_{t'}} r(s_{t}, a_{t})  \nabla_\theta \log \pi_\theta(a_{t'}|s_{t'})

=\sum_{t=1}^T \sum_{t'=t}^T  \mathop{E}_{s_t, a_t} \{r(s_{t}, a_{t})  \mathop{E}_{ s_{t'}, a_{t'}}[\nabla_\theta \log \pi_\theta(a_{t'}|s_{t'}) ]\}

式子中关于t‘的数学期望是零。证明方法是把数学期望写成积分,引入 a_{t'} 出现的概率,之后可得0。证毕。

编辑于 2019-04-01

文章被以下专栏收录