【一文弄懂】优先经验回放(PER)论文-算法-代码

22 篇文章 4 订阅
5 篇文章 4 订阅

【一文弄懂】优先经验回放(PER)论文-算法-代码

前言:

这是我replay buffer系列博客的第一篇,PER,接下来会整理HER和另一个叫不出名字的。

ps: 欢迎做强化的同学加群一起学习:
深度强化学习-DRL:799378128

综合评价:

如果算法足够好,没必要用PER.
如果奖励函数足够dense,也没有必要用PER.
因为用了PER速度大大降低,性能不一定会提高。
如果真实场景交互,CPU资源足够,稀疏奖励,可以试试。

继续前言唠叨

去年挖的坑,今年才填上。
最近做了真实机器人实验,发现model-free的探索效率实在是太低了。
而且单个机器人调个参数都花老大劲儿了。
最近半个月都在尝试叠buff,看看能不能把大家常说的HER,PER加上去,让强化学的更快点。

因此上上周调了HER的代码,发现盛名之下,其实难副,首先最原始的her本身就有不少缺陷。在它最擅长的任务中,它还需要花差不多1e6以上的交互回合,才能学会push任务。这和我想象的,一学就会差距太大了。

所以上周三开始捡起去年用了一半的PER,优先经验回放,听说这个也能加速学习,现在整理完了,发现也就那样~

总的来说,目前HER和PER是众多花里胡哨的提升技巧中,最成熟,听起来最make sense的trick了。

但是当时我用的PER是在莫烦的基础改的,莫烦没有实现TD3,我自己在他那个DDPG上改的TD3效果不行(当时没找到错误的原因在哪儿,应该是对TensorFlow理解不够透彻)

因此我需要重写一个基于spinningup风格的PER模块。
并且针对PER,对每个算法(DDPG, TD3,SAC)都写一个RL+PER的类。

前天测试了一下TD3-PER和TD3跑效果。
发现随机性太大!
简直离谱,具体的实验图,放到博客的最后面,有兴趣的可以看看。
下面主要是整理PER的算法和原理。

per论文简述:

参考博客:

(作为学习笔记,很多内容,我就直接复制粘贴了)
借鉴其背景知识和重要性采样— 【DRL-5】Prioritized Experience Replay
借鉴其sumtree部分— 4. [2015] [PER] Prioritized Experience Replay

借鉴其sumtree部分----RL 7.Prioritized Experience Replay经验回放
论文原文链接:
Prioritized Experience Replay

背景知识

之前重用经验(experience,transition转换五元组[s, a, r, s’, d]都是从经验池中均匀采样,忽略了经验的重要程度,文中提到的优先经验回放框架按经验重要性增大其被采样到的概率,希望越重要的经验使用次数越多,从而增加学习效率。
文中应用算法:DQN
效果:相比传统经验池机制,Atari 49游戏中41胜,8负

Prioritized Experience Replay的想法可能来自Prioritized sweeping,这是经典强化学习时代就已经存在的想法了,Sutton那本书上也有说过。

所以Prioritized Experience Replay实际上就是把这个想法应用在DQN上,做了一个“微小的改动”,然而论文莫名的长,性能的提升也相当大。

注意,per最先是用在DQN上的!17年有一个小伙儿用在了DDPG上,测试的任务比较简单,但是测试的超参数比较多,可以作为参考。
我今天要做的是将TD3加上PER,之前也有人做过,但好像也没有放出测试结果。

首先,让我们来回忆一下DQN的Experience Replay是怎么做的,其实无非两点

  • 保持队列的结构,先进先出,当长度到达定值后开始踢人
  • 从整个Buffer中均匀分布随机选择
    然而,在DQN数以千计的样本,也就是 [s, a, r, s’] 中,一定有一些样本可以帮助DQN更快的收敛;或者说,存在一些样本,如果我们先学了这些样本,后面的就不用再学了。

如果我们的样本出现的顺序可以是提前设计好的,那么就一定可以加速训练的过程。

当然,实际上想找到某个“出现顺序”,使得训练速度最快,这涉及到一个复杂的全局搜索,有那工夫还不如直接随机训练呢,但是作者给我们准备了一个例子,用来观察样本次序的重要性。

A MOTIVATING EXAMPLE

对于这种新算法,老喜欢先整一个motivation example了,或者toy study,HER也是。
有了这玩意儿,算法在极端情况下一跑,效果拔群,就会给读者一种make sense的好印象,值得学习。

在这里插入图片描述

上图是一个简单的环境,每一个箭头代表了一次状态转移,它们的概率都是1/2。

也就是说,想得到唯一的奖赏,需要从状态 [n] 走绿色的箭头走到状态 [1]。如果我们从状态 [1] 出发,随机游走,出现这种情况的可能是 [1/(2^n)] 。

可以看出,这是一个reward sparse的问题,上面我跑的结果不明显,可能是因为我没有用sparse奖励?

如果按照DQN的方法来,需要漫长的训练,而如果有一个最优的次序,论文中称为“oracle”,则可以指数级的缩减时间。

然而,问题在于,这只是一个小的toy example,我们可以全局搜索去找这个oracle,然而当我们处理实际问题的时候,如何去找这个次序呢?

methods:PER

这就是Prioritized Experience Replay要解决的问题,它从传统RL那里继承了使用TD-error的想法。

TD-error就是指在时序差分中这个当前Q值和它目标Q值的差值。

比如我们有一个样本 [s, a, r, s’] ,那么它的TD-error应该是

在这里插入图片描述

当然,考虑到DQN的Target Network,应该是

[公式]

再考虑到Double DQN,应该是

在这里插入图片描述

总之,无论你用哪种方法,你都要算出一个 y ,而TD-error就是

[公式]

在DQN中,我们训练的目标就是让 δ \delta δ的期望尽可能小,这样来看,我们根据 δ \delta δ 选择训练次序是有道理的。

当然,实际上我们应该是按照 ∣ δ ∣ |\delta| δ 的大小来选择。

从某种角度,我们可以说TD-error表示某个转移有多么“令人惊讶”或“出乎意料”。它指出了当前的Q值和下一步应该追求的Q值差距有多大。

既然按照 a b s ( δ ) abs(\delta) abs(δ) 选择次序,那最简单的方法显然就是greedy,我们可以维护一个二元堆,实现一个优先队列。

取出的时间复杂度是 O ( 1 ) O(1) O(1),更新的时间复杂度是 O ( n ) O(n) O(n) 。老数据结构了。

但是用绝对的次序看起来不太好,比如

很可能一个 ( s , a ) (s, a) (s,a) 在第一次访问的时候 ∣ δ ∣ |\delta| δ 很小,因此被排到后面,但是之后的网络调整让这个的 ∣ δ ∣ |\delta| δ变得很大,然而我们很难再调整它了(必须把它前面的都调整完)
r r r是随机的时候(也就是环境具有随机性), ∣ δ ∣ |\delta| δ 的波动就很剧烈,这会让训练的过程变得很敏感。
greedy的选择次序很可能让神经网络总是更新某一部分样本,也就是“经验的一个子集”,这可能陷入局部最优,也可能导致过拟合的发生(尤其是在函数近似的过程中)。
总之,我们应该寻找一种更具有随机性的方法。

两种确定优先度方法:

其实这个过渡还是比较自然的,我们有两种方法可以做到这一点。这两种方法都需要下面的计算式
在这里插入图片描述
也就是说,对于Buffer中的每一个样本,我们都计算出一个概率出来,根据这个概率来采样。

proportional prioritization中,我们直接根据 ∣ δ ∣ |\delta| δ 决定概率
[公式]
而rank-based prioritization中,我们根据rank来决定概率
在这里插入图片描述
这个 rank(i) 就是第 i 个样本在全体样本中排在多少位,按照对应的 ∣ δ ∣ |\delta| δ 由大到小排列。
这种方式更具鲁棒性,因为其对异常点不敏感,主要是因为异常点的TD-error过大或过小对rank值没有太大影响

优点: 其重尾性、厚尾性、heavy-tail property保证采样多样性
分层采样使mini-batch的梯度稳定
缺点: 当在稀疏奖励场景想要使用TD-error分布结构时,会造成性能下降
实际上,在作者们的实验中,这两种方法的表现大致相同。

比较
根据文中实验,两种方式效果基本相同,但不同场景可能一个效果很好,一个效果一般般。作者猜想效果相同的原因可能是因为对奖励和TD-error大量使用clip操作,消除了异常值,作者本以为Rank-based更具鲁棒性的。

Overhead is similar to rank-based prioritization.

两者开销相同。

New transitions arrive without a known TD-error, so we put them at maximal priority in order to guarantee that all experience is seen at least once.

新的经验被存入经验池时不需计算TD-error,直接将其设置为当前经验池中最大的TD-error,保证其至少被抽中一次。
既然使用TD-error作为衡量可学习的度量,那么完全可以用贪婪的方式,选取TD-error最大的几个进行学习,但这会有几个问题:

由于只有在经验被重放之后,这个经验的TD-error才被更新,导致初始TD-error比较小的经验长时间不被使用,甚至永远不被使用。
贪婪策略聚焦于一小部分TD-error比较高的经验,当使用值函数近似时,这些经验的TD-error减小速度很慢,导致这些经验被高频重复使用,致使样本缺乏多样性而过拟合。
文中提到使用随机采样方法 stochastic sampling method在贪婪策略与均匀采样之间“差值”来解决上述问题,其实它是一个在样本使用上的trade-off,由超参数[公式]控制

使用优先经验回放还有一个问题是改变了状态的分布,我们知道DQN中引入经验池是为了解决数据相关性,使数据(尽量)独立同分布的问题。但是使用优先经验回放又改变了状态的分布,这样势必会引入偏差bias,对此,文中使用偏差退火——重要性采样结合退火因子,来消除引入的偏差。

在这里插入图片描述

rank的形式没有看到代码实现,也没有看到解析,下面主要讲比例排序的,基于sumtree

sumtree 代码实现

如果每次抽样都需要针对 p 对所有样本排序, 这将会是一件非常消耗计算能力的事. 可以采用更高级的算法——SumTree方法。

SumTree是一种树形结构,每片树叶存储每个样本的优先级P,每个树枝节点只有两个分叉,节点的值是两个分叉的和,所以SumTree的顶端就是所有p的和。正如下面图片(来自Jaromír Janisch), 最下面一层树叶存储样本的p, 叶子上一层最左边的 13 = 3 + 10, 按这个规律相加, 顶层的 root 就是全部p的和了:

在这里插入图片描述
抽样时, 我们会将 p 的总和除以 batch size, 分成 batch size 那么多区间, (n=sum ( p) / batch_size). 如果将所有 node 的 priority 加起来是42的话, 我们如果抽6个样本, 这时的区间拥有的 priority 可能是这样:

[0-7], [7-14], [14-21], [21-28], [28-35], [35-42]

然后在每个区间里随机选取一个数. 比如在第区间 [21-28] 里选到了24, 就按照这个 24 从最顶上的42开始向下搜索. 首先看到最顶上 42 下面有两个 child nodes, 拿着手中的24对比左边的 child 29, 如果 左边的 child 比自己手中的值大, 那我们就走左边这条路, 接着再对比 29 下面的左边那个点 13, 这时, 手中的 24 比 13 大, 那我们就走右边的路, 并且将手中的值根据 13 修改一下, 变成 24-13 = 11. 接着拿着 11 和 13 左下角的 12 比, 结果 12 比 11 大, 那我们就选 12 当做这次选到的 priority, 并且也选择 12 对应的数据。

换个例子,所有的经验回放样本只保存在最下面的叶子节点上面,一个节点一个样本。内部节点不保存样本数据。而叶子节点除了保存数据以外,还要保存该样本的优先级,就是图中的显示的数字。对于内部节点每个节点只保存自己的儿子节点的优先级值之和,如图中内部节点上显示的数字。

这样保存有什么好处呢?主要是方便采样。以上面的树结构为例,根节点是42,如果要采样一个样本,那么我们可以在[0,42]之间做均匀采样,采样到哪个区间,就是哪个样本。比如我们采样到了26, 在(25-29)这个区间,那么就是第四个叶子节点被采样到。而注意到第三个叶子节点优先级最高,是12,它的区间13-25也是最长的,会比其他节点更容易被采样到。

如果要采样两个样本,我们可以在[0,21],[21,42]两个区间做均匀采样,方法和上面采样一个样本类似。

重要性采样:

除了经验回放池,现在我们的Q网络的算法损失函数也有优化,之前我们的损失函数是:

在这里插入图片描述
现在我们新的考虑了样本优先级的损失函数是:

在这里插入图片描述
这里面的区别在于,多了一个 ω j \omega_j ωj

使用了Prioritized Experience Replay之后,样本的分布就被改变了,这可能导致我们的模型收敛到不同的值(把机器学习看作一种密度估计就会很容易理解,当然我没理解…)。

与此同时,我们又不想放弃Prioritized Experience Replay带来的速度提升,那就只能在采样上下功夫。

我们可以使用重要性采样,这样既保证每个样本被选到的概率是不同的(从而提升训练速度),又可以保证它们对梯度下降的影响是相同的(从而保证收敛到相同的结果)。

我们可以设计一个重要性采样的权重 ω j \omega_j ωj
在这里插入图片描述
这里的 N N N就是Buffer里的样本数,而 β \beta β是一个超参数,用来决定你有多大的程度想抵消Prioritized Experience Replay对收敛结果的影响。
如果 β = 1 \beta=1 β=1 ,则代表完全抵消掉影响,这样的话Prioritized Experience Replay和DQN中的Experience Replay就没区别了。

重要性采样ISweight及化简 :

为什么要用重要性采样,原理,可以见我未来的博客…
这个坑先挖着。。。
也可以直接看李宏毅大佬的b站课程-ppo那一节。

这个 ISweight 到底怎么算. 需要提到的一点是, 代码中的计算方法是经过了简化的, 将 paper 中的步骤合并了一些.
比如
prob = p / self.tree.total_p;
ISWeights = np.power(prob/min_prob, -self.beta)
在这里插入图片描述
下面是莫烦的推导, 在paper 中,
I S W e i g h t = w j = ( N ∗ P j ) − b e t a / m a x i ( w i ) ISWeight =w_j= (N*P_j)^{-beta}/max_i(w_i) ISWeight=wj=(NPj)beta/maxi(wi)
里面的 m a x i ( w i ) max_i(w_i) maxi(wi) 是为了 normalize ISWeight,
在这里插入图片描述

这里面的意思是我当前选择的样本是j,我拿到的权重稀疏是 w j w_j wj,但是我要归一化一下,想到的法子是除以所有样本中最大的那个权重样本i,那么用 m a x i ( w i ) max_{i}(w_i) maxi(wi)来表示。
单纯的 importance sampling 就是 ( N ∗ P j ) − b e t a (N*P_j)^{-beta} (NPj)beta,
m a x i ( w i ) = m a x i [ ( N ∗ P i ) − b e t a ] max_i(w_i) = max_i[(N*P_i)^{-beta}] maxi(wi)=maxi[(NPi)beta].

如果将这两个式子合并,
I S W e i g h t = ( N ∗ P j ) − b e t a / m a x i [ ( N ∗ P i ) − b e t a ] ISWeight = (N*P_j)^{-beta} / max_i[(N*P_i)^{-beta} ] ISWeight=(NPj)beta/maxi[(NPi)beta]

而且如果将
m a x i [ ( N ∗ P i ) − b e t a ] max_i[(N*P_i)^{-beta}] maxi[(NPi)beta] 中的 (-beta) 提出来,
这就变成了
[ m i n i ( N ∗ P i ) ] − b e t a [min_i(N*P_i) ] ^ {-beta} [mini(NPi)]beta

看出来了吧, 有的东西可以抵消掉的. 最后
I S W e i g h t = ( P j / m i n i ( P i ) ) − b e t a ISWeight = (P_j / min_i(P_i))^{-beta} ISWeight=(Pj/mini(Pi))beta
这样我们就有了代码中的样子.
或者直接看下面的公式:
在这里插入图片描述

ISWeights计算代码:

因此ISWeights的计算代码是这样的:

def sample(self, n):
        b_idx, b_memory, ISWeights = np.empty((n,), dtype=np.int32), np.empty((n, self.tree.data[0].size)), np.empty((n, 1))
        pri_seg = self.tree.total_p / n       # priority segment
        # beta是逐渐增加的,最终为1
        self.beta = np.min([1., self.beta + self.beta_increment_per_sampling])  # max = 1

        min_prob = np.min(self.tree.tree[-self.tree.capacity:]) / self.tree.total_p     # for later calculate ISweight
        for i in range(n):
            a, b = pri_seg * i, pri_seg * (i + 1)
            v = np.random.uniform(a, b)
            idx, p, data = self.tree.get_leaf(v)
            prob = p / self.tree.total_p
            ISWeights[i, 0] = np.power(prob/min_prob, -self.beta)
            b_idx[i], b_memory[i, :] = idx, data
        return b_idx, b_memory, ISWeights

sumtree代码:

class SumTree(object):
    """
    This SumTree code is a modified version and the original code is from:
    https://github.com/jaara/AI-blog/blob/master/SumTree.py

    Story data with its priority in the tree.
    """
    data_pointer = 0

    def __init__(self, capacity):
        self.capacity = capacity  # for all priority values
        self.tree = np.zeros(2 * capacity - 1)
        # [--------------Parent nodes-------------][-------leaves to recode priority-------]
        #             size: capacity - 1                       size: capacity
        self.data = np.zeros(capacity, dtype=object)  # for all transitions
        # [--------------data frame-------------]
        #             size: capacity

    def add(self, p, data):
        tree_idx = self.data_pointer + self.capacity - 1
        self.data[self.data_pointer] = data  # update data_frame
        self.update(tree_idx, p)  # update tree_frame

        self.data_pointer += 1
        if self.data_pointer >= self.capacity:  # replace when exceed the capacity
            self.data_pointer = 0

    def update(self, tree_idx, p):
        change = p - self.tree[tree_idx]
        self.tree[tree_idx] = p
        # then propagate the change through tree
        while tree_idx != 0:    # this method is faster than the recursive loop in the reference code
            tree_idx = (tree_idx - 1) // 2
            self.tree[tree_idx] += change

    def get_leaf(self, v):
        """
        Tree structure and array storage:

        Tree index:
             0         -> storing priority sum
            / \
          1     2
         / \   / \
        3   4 5   6    -> storing priority for transitions

        Array type for storing:
        [0,1,2,3,4,5,6]
        """
        parent_idx = 0
        while True:     # the while loop is faster than the method in the reference code
            cl_idx = 2 * parent_idx + 1         # this leaf's left and right kids
            cr_idx = cl_idx + 1
            if cl_idx >= len(self.tree):        # reach bottom, end search
                leaf_idx = parent_idx
                break
            else:       # downward search, always search for a higher priority node
                if v <= self.tree[cl_idx]:
                    parent_idx = cl_idx
                else:
                    v -= self.tree[cl_idx]
                    parent_idx = cr_idx

        data_idx = leaf_idx - self.capacity + 1
        return leaf_idx, self.tree[leaf_idx], self.data[data_idx]

    @property
    def total_p(self):
        return self.tree[0]  # the root

因为 SumTree 有特殊的数据结构, 所以两者都能用一个一维 np.array 来存储。

  • capacity 表示这个SumTree能存多少组数据。
  • self.tree = np.zeros(2 * capacity - 1)中,前capacity - 1个节点为非叶子节点,后capacity个为叶子节点。self.data = - np.zeros(capacity, dtype=object)为所有的优先级,共capacity个。
  • add()函数是:当有新 sample 时, 添加进 tree 和 data。
  • get_leaf(self, v)函数是:根据选取的 v 点抽取样本。
    说了这么多这个class怎么用呢?

这个class说白了就是个存储器,用来存取样本,只不过这个存储器Memory长成了一个树的样子。

我们知道,一个样本不仅包含优先级信息,还包括其他的数据。所以我用self.tree.add(maxp, transition)
这句话完成对它的存储,句中max_p为优先级信息,transition为其他的数据。

self.tree存储优先级信息,self.data存储数据信息。
所以,当要采样的时候,我拿一个随机数v,输入到get_leaf(self, v)这个函数里面:

  • 采样得到的样本是:leaf_idx。
  • 这个样本的优先级是:self.tree[leaf_idx]。
  • 这个样本存储的数据是:self.data[data_idx]。
    最后要明确的是每个信息都可以看成是1个3元组:(tree_idx,p优先级,data数据)。

DQN-PER-Memory代码:

我不复制了,太长了,有需要的直接看下面链接就行了。
https://zhuanlan.zhihu.com/p/160186240

TD3-PER代码:

装上gym就可以直接跑的,欢迎点个star~

https://github.com/kaixindelele/DRL-tensorflow/blob/master/td3_sp/TD3_per_class.py

相比普通的TD3,修改的内容有:

1.增加计算abs_error的句柄:
self.abs_errors = tf.abs(min_q_targ - self.q1)
2.更新q值的时候,增加重要性采样的系数
self.q_loss = self.ISWeights * (q1_loss + q2_loss)
3.修改存储transition,因为per_buffer里面,存的格式是列表,不需要拆解transition。
self.replay_buffer.store(transition)
4.修改更新参数的函数,对buffer的采样,数据格式也不一样:
tree_idx, batch_memory, ISWeights = self.replay_buffer.sample(batch_size=batch_size)
但这个其实改的可以很少。

TD3+PER的一些简单测试

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

hehedadaq

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值