找回密码
 立即注册
查看: 321|回复: 2

影响PPO算法性能的10个关键技巧(附PPO算法简洁Pytorch ...

[复制链接]
发表于 2022-5-16 09:18 | 显示全部楼层 |阅读模式
0.引言

PPO算法(Proximal Policy Optimization)[1]是目前深度强化学习(DRL)领域,最广泛应用的算法之一。然而才实际应用的过程中,PPO算法的性能却受到多种因素的影响。本文总结了影响PPO算法性能的10个关键技巧,并通过实验结果的对比,来探究各个trick对PPO算法性能的影响。同时,我们将代码开源在了github上,分别提供了PPO算法的离散动作空间实现和连续动作空间实现(见下面github链接中的4.PPO-discrete和5.PPO-continuous(包括了Gaussian分布和Beta分布))
1.PPO算法概述

PPO算法的核心是使用如下策略损失函数:


其中,


表示新旧策略之间的比率。  为一个超参数,用来确保当利用同一批数据进行多次策略更新时,“新旧策略之间的差距不要太大”。一般情况下,我们设置
(1)式中, 为优势函数的估计,根据PPO的原始论文建议,我们一般使用GAE(generalized advantage estimation)[2]来计算优势函数


其中, 为:


以上便是PPO原始论文的核心内容。其实在PPO的原始论文中,除了利用GAE计算优势函数外,并没有提到其他的实现细节和技巧。但是在实际的各种代码实现,例如Open AI Baseline、Open AI Spinning Up中,却包括了许许多多的“trick”,实验表明,这些trick都会在一定程度上影响PPO算法的性能。我在参考了《PPO-Implementation matters in deep policy gradients A case study on PPO and TRPO》[3]这篇论文,以及下面这篇博客后
通过自己的亲身实践,总结了影响PPO算法性能最关键的10个trick,如下表所示:
Trick 1Advantage Normalization
Trick 2State Normalization
Trick 3 & Trick 4Reward Normalization & Reward Scaling
Trick 5Policy Entropy
Trick 6Learning Rate Decay
Trick 7Gradient clip
Trick 8Orthogonal Initialization
Trick 9Adam Optimizer Epsilon Parameter
Trick 10Tanh Activation Function
我们把集成了上述10个trick后的PPO算法命名为PPO-max,而不使用这10个trick的最基础的PPO算法命名为PPO-min。我们在gym中的四个连续动作空间环境下(BipedalWalker-v3、HalfCheetah-v2、Hopper-v2、Walker2d-v2)分别进行了实验,训练结果对比如图1所示。
注:
1.关于Trick 3 & Trick 4,由于只能对reward进行一种操作,我们默认选择使用Reward Scaling。
2.因为是连续动作空间,因此我们默认使用Gaussian分布来输出动作。
3.我们在每个环境中都使用了3个随机种子进行实验,并使用seaborn画图,采用“滑动平均”的方法来平滑训练曲线,后文的实验结果图同理。



图1 PPO-max vs PPO-min

通过对比可以看出,不使用上述10个trick的PPO-min在四个gym环境上几乎无法训练,而PPO-max均可以到达非常理想的训练效果。在3M steps内,PPO-max在HalfCheetah-v2环境上reward可以达到6700,在Hopper-v2环境下可以达到3600,Walker2d-v2环境下可以达到5500。对比https://iclr-blog-track.github.io/2022/03/25/ppo-implementation-details/这篇博客中给出的各个强化学习算法库的训练效果,除了“Tianshou”外,我们的实验结果均更胜一筹。



图2 各种强化学习算法库的PPO训练结果

我们将代码开源在了github上,分别提供了PPO算法的离散动作空间实现和连续动作空间实现(见下面github链接中的4.PPO-discrete和5.PPO-continuous(包括了Gaussian分布和Beta分布),如果这份代码对您有帮助,欢迎您给一个star~)
2. 探究影响PPO算法性能的10个关键技巧

在这一节中,我们将逐一介绍上述PPO-max中10个trick的具体实现细节,并通过对比实验来探究这些trick究竟对PPO算法的性能有什么影响。(注:下面的实验讨论均已连续动作空间下Gaussian分布为例)
Trick 1—Advantage Normalization

在论文《The Mirage of Action-Dependent Baselines in Reinforcement Learning》[4]中提出了对advangate进行normalization的操作,可以提升PG算法的性能。具体代码实现层面,对advantage做normalization的方式主要有两种:
(1)batch adv norm:使用GAE计算完一个batch中的advantage后,计算整个batch中所有advantage的mean和std,然后减均值再除以标准差。
(2)minibatch adv norm:使用GAE计算完一个batch中的advantage后,不是直接对整个batch的advangate做normalization,而是在用每次利用minibatch更新策略之前,对当前这个minibatch中的advangate做normalization。(https://iclr-blog-track.github.io/2022/03/25/ppo-implementation-details/ 这篇博客中使用的就是minibatch adv norm)
是否使用advantage normalization,以及batch adv norm和minibatch adv norm的对比如图3所示。在我们的PPO-max中,默认使用的是batch adv norm(红色曲线);如果关闭batch adv norm(棕色曲线),PPO算法几乎无法训练,由此可见advantage normalization对PPO算法的性能有非常重要的影响。如果把batch adv norm替换成minibatch adv norm(黑色曲线),训练性能会有一定程度的下降。



图3 batch adv norm VS minibatch dv norm

从原理角度分析,我个人认为如果是对每一个minibatch中的advangate单独做normalization,可能会导致每一次计算的mean和std有较大的波动,从而对算法的训练带来一定负面影响。因此,我们在这里建议使用batch adv norm的方式。
Trick 2—State Normalization

state normalization的核心在于,与环境交互的过程中,维护一个动态的关于所有经历过的所有state的mean和std, 然后对当前的获得的state做normalization。经过normalization后的state符合mean=0,std=1的正态分布,用这样的状态作为神经网络的输入,更有利于神经网络的训练。
具体实现方式如下:
(1)首先定义一个动态计算mean和std的class,名为RunningMeanStd。这个class中的方法的核心思想是已知n个数据的mean和std,如何计算n+1个数据的mean和std
注:这种方法计算的mean是准确的,但是std只是估计值,与真实std有一点微小偏差。使用这种方法计算mean和std的主要原因是,我们不可能事先初始化一个无限大的buffer,每获得一个新的状态数据后都重新计算buffer中所有数据的mean和std,这样太浪费计算资源和时间,也不现实。
class RunningMeanStd:
    # Dynamically calculate mean and std
    def __init__(self, shape):  # shape:the dimension of input data
        self.n = 0
        self.mean = np.zeros(shape)
        self.S = np.zeros(shape)
        self.std = np.sqrt(self.S)

    def update(self, x):
        x = np.array(x)
        self.n += 1
        if self.n == 1:
            self.mean = x
            self.std = x
        else:
            old_mean = self.mean.copy()
            self.mean = old_mean + (x - old_mean) / self.n
            self.S = self.S + (x - old_mean) * (x - self.mean)
            self.std = np.sqrt(self.S / (self.n - 1))(2)定义一个名叫Normalization的类,其中实例化上面的RunningMeanStd,需要传入的参数shape代表当前环境的状态空间的维度。训练过程中,每次得到一个state,都要把这个state传到Normalization这个类中,然后更新mean和std,再返回normalization后的state。
class Normalization:
    def __init__(self, shape):
        self.running_ms = RunningMeanStd(shape=shape)

    def __call__(self, x, update=True):
        # Whether to update the mean and std,during the evaluating,update=Flase
        if update:  
            self.running_ms.update(x)
        x = (x - self.running_ms.mean) / (self.running_ms.std + 1e-8)

        return x是否使用state noramalization的对比如图4所示,红色曲线为PPO-max,蓝色曲线为在PPO-max的基础上关闭state normalization。通过对比可以看出,state normalization这个trick对PPO算法的整体性能有一定提升。



图4 是否使用state normalization

Trick 3 & Trick 4—— Reward Normalization & Reward Scaling

对reward的处理,目前有reward normalization和reward scaling两种方式:这两种处理方式的目的都是希望调整reward的尺度,避免因过大或过小的reward对价值函数的训练产生负面影响。
(1)reward normalization:与state normalization的操作类似,也是动态维护所有获得过的reward的mean和std,然后再对当前的reward做normalization。
(2)reward scaling:在《PPO-Implementation matters in deep policy gradients A case study on PPO and TRPO》[3]这篇论文中,作者中提出了一种名叫reward scaling的方法,如图5所示。reward scaling与reward normalization的区别在于,reward scaling是动态计算一个standard deviation of a rolling discounted sum of the rewards,然后只对当前的reward除以这个std。



图5 PPO reward scaling

reward scaling的代码实现被集成在了RewardScaling这个class中,具体代码如下:
class RewardScaling:
    def __init__(self, shape, gamma):
        self.shape = shape  # reward shape=1
        self.gamma = gamma  # discount factor
        self.running_ms = RunningMeanStd(shape=self.shape)
        self.R = np.zeros(self.shape)

    def __call__(self, x):
        self.R = self.gamma * self.R + x
        self.running_ms.update(self.R)
        x = x / (self.running_ms.std + 1e-8)  # Only divided std
        return x

    def reset(self):  # When an episode is done,we should reset 'self.R'
        self.R = np.zeros(self.shape)reward norm和reward scaling的对比如图6所示。图中,PPO-max(红色)中默认使用的是reward scaling,去掉reward scaling后(橙色),性能有一定程度下降;如果把PPO-max中的reward scaling 换成reward norm(紫色),在HalfCheetah-v2、Hopper-v2和Walker-v2这三个环境下,对训练性能的伤害非常严重。因此,我们建议使用reward scaling而不是reward normalization。



图6 reward scaling or reward normalization

Trick 5—Policy Entropy

在信息论与概率统计中,熵(entropy)是表示随机变量不确定性的度量。在强化学习中,策略的熵可以表示为:


一个策略的熵越大,意味着这个策略选择各个动作的概率更加“平均”。在PPO中,为了提高算法的探索能力,我们一般在actor的loss中增加一项策略熵,并乘以一个系数entropy_coef,使得在优化actor_loss的同时,让策略的熵尽可能大。一般我们设置entropy_coef=0.01。
是否使用策略熵的对比如图7所示,通过对比可以看出,使用策略熵可以一定程度提高训练效果,如果在PPO-max(红色)的基础上不适用策略熵(即令entropy_coef=0,图中粉色),reward往往会收敛到次优解。



图7 是否使用策略熵

Trick 6—Learning Rate Decay

学习率衰减可以一定程度增强训练后期的平稳性,提高训练效果。这里我们采用线性衰减学习率的方式,使lr从初始的3e-4,随着训练步数线性下降到0,具体代码如下:
def lr_decay(self, total_steps):
    lr_a_now = self.lr_a * (1 - total_steps / self.max_train_steps)
    lr_c_now = self.lr_c * (1 - total_steps / self.max_train_steps)
    for p in self.optimizer_actor.param_groups:
        p['lr'] = lr_a_now
    for p in self.optimizer_critic.param_groups:
        p['lr'] = lr_c_now是否使用学习率衰减的对比如图8所示,通过对比可以看出,学习率衰减这一trick的确对训练效果有一定帮助。



图8 是否使用学习率衰减

Trick 7—Gradient clip

梯度剪裁是为了防止训练过程中梯度爆炸从而引入的一项trick,同样可以起到稳定训练过程的作用。在Pytorch中,梯度剪裁只需要在更新actor和critic时增加一条语句即可实现。
# Update actor
self.optimizer_actor.zero_grad()
actor_loss.mean().backward()
if self.use_grad_clip: # Trick 7: Gradient clip
    torch.nn.utils.clip_grad_norm_(self.actor.parameters(), 0.5)
self.optimizer_actor.step()

# Update critic
self.optimizer_critic.zero_grad()
critic_loss.backward()
if self.use_grad_clip: # Trick 7: Gradient clip
    torch.nn.utils.clip_grad_norm_(self.critic.parameters(), 0.5)
self.optimizer_critic.step()是否增加gradient clip的对比如图9所示,通过对比可以看出,gradient clip对训练效果有一定提升。



图9 是否增加gradient clip

Trick 8—Orthogonal Initialization

正交初始化(Orthogonal Initialization)是为了防止在训练开始时出现梯度消失、梯度爆炸等问题所提出的一种神经网络初始化方式。具体的方法分为两步:
(1)用均值为0,标准差为1的高斯分布初始化权重矩阵,
(2)对这个权重矩阵进行奇异值分解,得到两个正交矩阵,取其中之一作为该层神经网络的权重矩阵。
使用正交初始化的Actor和Critic实现如下面的代码所示:
注:
1. 我们一般在初始化actor网络的输出层时,会把gain设置成0.01,actor网络的其他层和critic网络都使用Pytorch中正交初始化默认的gain=1.0。
2. 在我们的实现中,actor网络的输出层只输出mean,同时采用nn.Parameter的方式来训练一个“状态独立”的log_std,这往往比直接让神经网络同时输出mean和std效果好。(之所以训练log_std,是为了保证std=exp(log_std)>0)
# orthogonal init
def orthogonal_init(layer, gain=1.0):
    nn.init.orthogonal_(layer.weight, gain=gain)
    nn.init.constant_(layer.bias, 0)

class Actor_Gaussian(nn.Module):
    def __init__(self, args):
        super(Actor_Gaussian, self).__init__()
        self.max_action = args.max_action
        self.fc1 = nn.Linear(args.state_dim, args.hidden_width)
        self.fc2 = nn.Linear(args.hidden_width, args.hidden_width)
        self.mean_layer = nn.Linear(args.hidden_width, args.action_dim)
        self.log_std = nn.Parameter(torch.zeros(1, args.action_dim))  # We use 'nn.Paremeter' to train log_std automatically
        if args.use_orthogonal_init:
            print("------use_orthogonal_init------")
            orthogonal_init(self.fc1)
            orthogonal_init(self.fc2)
            orthogonal_init(self.mean_layer, gain=0.01)

    def forward(self, s):
        s = torch.tanh(self.fc1(s))
        s = torch.tanh(self.fc2(s))
        mean = self.max_action * torch.tanh(self.mean_layer(s))  # [-1,1]->[-max_action,max_action]
        return mean

    def get_dist(self, s):
        mean = self.forward(s)
        log_std = self.log_std.expand_as(mean)  # To make 'log_std' have the same dimension as 'mean'
        std = torch.exp(log_std)  # The reason we train the 'log_std' is to ensure std=exp(log_std)>0
        dist = Normal(mean, std)  # Get the Gaussian distribution
        return dist

class Critic(nn.Module):
    def __init__(self, args):
        super(Critic, self).__init__()
        self.fc1 = nn.Linear(args.state_dim, args.hidden_width)
        self.fc2 = nn.Linear(args.hidden_width, args.hidden_width)
        self.fc3 = nn.Linear(args.hidden_width, 1)
        if args.use_orthogonal_init:
            print("------use_orthogonal_init------")
            orthogonal_init(self.fc1)
            orthogonal_init(self.fc2)
            orthogonal_init(self.fc3)

    def forward(self, s):
        s = torch.tanh(self.fc1(s))
        s = torch.tanh(self.fc2(s))
        v_s = self.fc3(s)
        return v_s是否使用正交初始化的对比如图所10示,通过对比可以看出,正交初始化对训练性能有一定提高。



图10 是否使用正交初始化

Trick 9—Adam Optimizer Epsilon Parameter

pytorch中Adam优化器默认的eps=1e-8,它的作用是提高数值稳定性(pytorch官方文档中对Adam优化器的介绍如图11,eps即红框中的  )



图11 Pytorch官方文档中Adam优化器介绍

在Open AI的baseline,和MAPPO的论文里,都单独设置eps=1e-5,这个特殊的设置可以在一定程度上提升算法的训练性能。
是否设置Adam优化器中eps参数的对比如图10所示,通过对比可以看出,单独设置Adam优化器的eps=1e-5可以一定程度提升算法性能。



图12 是否设置Adam eps

Trick10—Tanh Activation Function

一般的强化学习算法,例如DDPG TD3 SAC等算法,都默认使用relu激活函数,但是经过实验表明,PPO算法更适合使用Tanh激活函数。在论文《PPO-Implementation matters in deep policy gradients A case study on PPO and TRPO》[3]中,作者也建议使用tanh激活函数。
使用tanh和relu激活函数的对比如图13所示,在PPO-max中我们默认使用的是tanh激活函数,把PPO-max中的激活函数替换成relu后(actor网络的最后一层mean_layer依然使用tanh),算法性能有了一定程度的下降。因此我们建议PPO算法默认使用tanh激活函数。



图13 激活函数tanh or relu

3.关于PPO算法的一些补充

3.1关于GAE的计算,以及done信号的区分

在gym的环境中,done=True有三种情况:

  • 游戏胜利(win)
2. 游戏失败(dead)
3. 达到最大步长
对于前两种情况,即dead or win的时候,我们可以认为当前的状态s就是终止状态,没有下一个状态s'的(例如BipedalWalker-v3这个环境,就会出现智能体通关或者摔倒的情形)。而对于第三种情况,即达到最大步长,这时本质上是人为“截断”了当前的回合,事实上当前的状态s并不是终止状态。因此我们在主循环中,需要对env.step()返回的done进行区分。为了区分前两种情况和第三种情况,我在程序中做了如下处理:
if done and episode_steps != args.max_episode_steps:
    dw = True
else:
    dw = False我用dw这个变量来表示dead or win这两种情况,然后同时向Buffer中存储dw和done。在计算GAE时,对dw和done进行区分:
if dw=True: deltas=r-v(s)
if dw=False:deltas=r+gamma*v(s')-v(s)
当done=True时,说明出现了上述三种情况的其中之一,即意味着一个episode的结束。在逆序计算adv时,遇到done=True,就要重新计算。
使用GAE计算advangate的具体代码如下
s, a, a_logprob, r, s_, dw, done = replay_buffer.numpy_to_tensor()  # Get training data
"""
    Calculate the advantage using GAE
    'dw=True' means dead or win, there is no next state s'
    'done=True' represents the terminal of an episode(dead or win or reaching the max_episode_steps). When calculating the adv, if done=True, gae=0
"""
adv = []
gae = 0
with torch.no_grad():  # adv and v_target have no gradient
    vs = self.critic(s)
    vs_ = self.critic(s_)
    deltas = r + self.gamma * (1.0 - dw) * vs_ - vs
    for delta, d in zip(reversed(deltas.flatten().numpy()), reversed(done.flatten().numpy())):
        gae = delta + self.gamma * self.lamda * gae * (1.0 - d)
        adv.insert(0, gae)
    adv = torch.tensor(adv, dtype=torch.float).view(-1, 1)
    v_target = adv + vs
    if self.use_adv_norm:  # Trick 1:advantage normalization
        adv = ((adv - adv.mean()) / (adv.std() + 1e-5))3.2 Guassian分布与Beta分布

一般的连续动作空间版本的PPO算法,都默认使用Gaussian分布来输出动作。在《Improving Stochastic Policy Gradients in Continuous Control with Deep Reinforcement Learning using the Beta Distribution 》[5]指出,由于Gaussian分布是一个无界的分布,我们在采样动作后往往需要clip操作来把动作限制在有效动作范围内,这个clip的操作往往会给算法性能带来负面影响。因此这篇论文提出采用一个有界的Beta分布来代替Guassain分布,通过Beta分布采样的动作一定在[0,1]区间内(Beta分布的概率密度函数曲线如图14所示),因此我们可以把采样到的[0,1]区间内的动作映射到任何我们想要的动作区间。



图14 Beta分布的概率密度函数曲线

在PPO中采用Beta分布的主要改动在于actor网络的定义,代码如下:
class Actor_Beta(nn.Module):
    def __init__(self, args):
        super(Actor_Beta, self).__init__()
        self.fc1 = nn.Linear(args.state_dim, args.hidden_width)
        self.fc2 = nn.Linear(args.hidden_width, args.hidden_width)
        self.alpha_layer = nn.Linear(args.hidden_width, args.action_dim)
        self.beta_layer = nn.Linear(args.hidden_width, args.action_dim)
        if args.use_orthogonal_init:
            print("------use_orthogonal_init------")
            orthogonal_init(self.fc1, gain=1.0)
            orthogonal_init(self.fc2, gain=1.0)
            orthogonal_init(self.alpha_layer, gain=0.01)
            orthogonal_init(self.beta_layer, gain=0.01)

    def forward(self, s):
        s = torch.tanh(self.fc1(s))
        s = torch.tanh(self.fc2(s))
        # alpha and beta need to be larger than 1,so we use 'softplus' as the activation function and then plus 1
        alpha = F.softplus(self.alpha_layer(s)) + 1.0
        beta = F.softplus(self.beta_layer(s)) + 1.0
        return alpha, beta

    def get_dist(self, s):
        alpha, beta = self.forward(s)
        dist = Beta(alpha, beta)
        return dist

    def mean(self, s):
        alpha, beta = self.forward(s)
        mean = alpha / (alpha + beta)  # The mean of the beta distribution
        return mean在一些环境下,例如HalfCheetah-v2,采用Beta有时会比Gaussian获得更好的效果,如图15所示。



图15 Gaussian VS Beta

因此,我们在PPO算法的实现中,集成了Beta分布,可以通过设置'policy_dist'参数来实现Gaussian分布和Beta分布之间的切换。
4. 总结

在这篇文章中,我根据自己个人的实际经验,列出了影响PPO算法性能的10个关键技巧,并通过对比实验来探究这些技巧对PPO算法性能的具体影响,同时给出了完整的PPO算法的pytorch实现(包括了离散动作版本和连续动作版本(集成Gaussian分布和Beta分布))。
同时,我想额外指出的是,我上面列出的这10条trick,也不一定在所有的情况下都有效。很多时候,深度强化学习算法的训练是一件很“玄”的事情,不同的环境,不同的状态空间、动作空间和奖励函数的设计,都会对算法的性能产生不同的影响。同样的一个trick,可能在一个任务中效果很好,在另一个任务中却完全不work。我总结这10条trick,更多的是希望给正在学习和使用PPO算法的朋友提供一个整体方向上的引导,当你在用PPO算法解决一个实际任务但是效果不理想时,或许可以尝试一下我列出的这几条trick。当然,过渡迷恋于调参往往是不可取的,我们应该把更多的注意力放在模型算法本身的改进上。
最后,由于本人水平有限,上述文章内容和代码中难免存在错误,欢迎大家与我交流,批评指正!
参考


  • ^Schulman J, Wolski F, Dhariwal P, et al. Proximal policy optimization algorithms[J]. arXiv preprint arXiv:1707.06347, 2017.
  • ^Schulman J, Moritz P, Levine S, et al. High-dimensional continuous control using generalized advantage estimation[J]. arXiv preprint arXiv:1506.02438, 2015.
  • ^abcEngstrom L, Ilyas A, Santurkar S, et al. Implementation matters in deep policy gradients: A case study on PPO and TRPO[J]. arXiv preprint arXiv:2005.12729, 2020.
  • ^Tucker G, Bhupatiraju S, Gu S, et al. The mirage of action-dependent baselines in reinforcement learning[C]//International conference on machine learning. PMLR, 2018: 5015-5024.
  • ^Chou P W, Maturana D, Scherer S. Improving stochastic policy gradients in continuous control with deep reinforcement learning using the beta distribution[C]//International conference on machine learning. PMLR, 2017: 834-843.

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
发表于 2022-5-16 09:22 | 显示全部楼层
太强了
发表于 2022-5-16 09:27 | 显示全部楼层
[开心]
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-9-22 11:21 , Processed in 0.095911 second(s), 26 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表