【一文弄懂】优先经验回放(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=(N∗Pj)−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}
(N∗Pj)−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[(N∗Pi)−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=(N∗Pj)−beta/maxi[(N∗Pi)−beta]
而且如果将
m
a
x
i
[
(
N
∗
P
i
)
−
b
e
t
a
]
max_i[(N*P_i)^{-beta}]
maxi[(N∗Pi)−beta] 中的 (-beta) 提出来,
这就变成了
[
m
i
n
i
(
N
∗
P
i
)
]
−
b
e
t
a
[min_i(N*P_i) ] ^ {-beta}
[mini(N∗Pi)]−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的一些简单测试