Reinforcement Learning

DQN及其相关变形

ERAF post this 9791 words blog on October 30, 2018

主要是介绍DQN和其中的相关变形,内容上的介绍可能和:对于强化学习的一些总结有所类似,好处当然就是多了一些代码的实现了;

DQN

首先就是经典的DQN了,对于其中的介绍 相关qlearning的off-policy和on-policy就不再赘述,在之前的上一篇文章中也介绍了很多,关于值函数近似的思想在这一篇 值函数和策略梯度也有所说明 这里更多的还是说明一下DQN和 值函数和策略梯度文章中提到的Qnet的区别所在,以下为相关伪代码:

神经网络拟合值函数带来的问题

翻看DQN的文章也可以知道,其中使用了卷积神经网络来进行值函数的逼近,这看样子是个没什么问题的操作 在之前的 值函数和策略梯度文章中同样的采用了FCN来进行近似表达值函数,但是这里不同之处在于 使用了图像数据来作为了状态的描述,毕竟直观的考虑:针对图像数据 使用神经网络来拟合映射,也会想到使用卷积神经网络来处理,毕竟本身的局部特征和平移等变性,十分适合处理图像数据 具体的可以参考文章:DL之CNN常见概念,但同样的 这里会带来一个问题,也就是:来自于训练数据的问题 我们都知道CNN本质上依旧是属于有监督学习的一种,因而训练数据也满足SL对于训练数据的基本要求:独立同分布;对于这个词理解分为两部分,首先是同分布 最近在看andrew ng的新书《machine learning yearning》,里面的「Setting up development and test sets」一章对于训练数据和测试数据的介绍:训练集应该有着相同分布,而开发集和测试集应该服从同一分布; 毕竟 认定输入空间中的样本有着一个隐含未知的分布,而训练数据都是从这个分布上独立采样而得的。回头看机器学习所干的事情,不也正是基于当前所获得的信息「历史数据」来训练学习建立模型,进而对于未来的数据进行预测和模拟,联想到交叉熵损失函数等等。不正是从部分来对于整体的拟合过程吗?为了让当前数据能够更好的体现整体 这里我们引入一个概念:总体代表性,希望数据有着总体代表性,进而说当前的数据不是特别抽取的 总结建立起来的模型不是基于个例得到的特殊情况。 而独立的从原有隐含未知的分布中抽取数据,也大大满足的总体代表性这一特性,毕竟独立抽取的各个数据得到的数据集总体代表性也大大强于有关系的数据,这也正是SL中对于独立同分布要求的原因所在。「进一步的可以参考统计学习来理解」

DQN的改进

那么 强化学习为什么不行呢?回顾强化学习的过程,其实就是产生一个马尔科夫链,显然上面的状态满足马尔科夫性,交互得到的序列有着一定的相关性,显然就打破了上述假设,学习得到的值函数模型可能存在波动,因而也就引出了DQN里面一大改进方法:replay buffer; 具体操作就是:agent在与环境交互的时候 将交互数据存放在一个库里面,然后训练的时候 从中随机采样数据进行训练;存入的交互数据包括$<s,a,next_s,r>$ ,同时将数据存入其中 其实也提高了交互数据的使用效率,如果单纯的qlearning 每一次模型使用 交互生成的数据进行学习,学习得到的样本直接进行丢弃,那么在训练这样大的网络结构的时候 往往需要多次迭代才能收敛,每次都需要一定数量的样本来计算梯度,导致的一个现象就是:训练花的时间还没有交互得到数据的时间长。replay buffer也就完美解决了这样的问题。 具体来说 实现的方法,这里我们设计一个经验重播的类,其中包括两个方法:添加数据和随机采样,如下:

class experience_buffer():
    def __init__(self,buffer_size=50000):
    #设置好缓冲区大小
        self.buffer=[]
        self.buffer_size=buffer_size
        
    def add(self,experience):
        if len(self.buffer)+len(experience)>=self.buffer_size:
            self.buffer[0:(len(self.buffer)+len(experience))-self.buffer_size]=[]
            #超过大小 清理前面的buffer_size个元素
        self.buffer.expend(experience)
    
    def sample(self,size):
        return np.reshape(np.array(random.sample(self.buffer,size)),[size,5])
    #采样size个,并变形成我们需要的shape

第二个改进也是15年文章里面的改进,引入了目标网络的概念来单独的处理TD算法中的TD偏差;我们都知道 强化学习在表格式的时候 直接使用$Q^*(s,a) = Q(s,a)+\alpha(r+\gamma \max_{a’}Q(s’,a’)-Q(s,a))$ 来对于值函数就直接更新了;但函数近似后不行,现在的值函数本质上就是个函数的形式如$Q=f(\theta;feature)$ ,那么对于它的优化与更新也更多的是采用诸如梯度下降等方法来对于权值θ进行更新,有:$TargetQ = r+\gamma \max_{a’}Q(s’,a’;\theta)$ 和 $L(\theta)=E[(TargetQ-Q(s,a;\theta))^2]$ 。因而目标网络的改进就在于这里的$TargetQ $使用了一个单独的网络 ;「emmm 这么一说 本来qlearning里面就不是按照交互顺序 而是使用的最大值来得到的对于当前值函数的估计,现在又进一步打破关联性 使用另一个网络的输出来进行估计 进一步减少关联性」也就是说现在有着$TargetQ = r+\gamma \max_{a’}Q(s’,a’;\theta^-)$ ,对于这个target 网络的权值$\theta^-$ 和前面的计算Q值的主网络不同,主网络是每一步一更新,而target网络每隔一段时间一更新;「联系后面的DDQN的话 两者的区别其实也就在于:DDQN是在选取动作的时候基于一个网络 而评价是基于另一个网络;也就是$TargetQ=R+\gamma Q(S_{t+1},argmax_aQ(S_{t+1},a;\theta_t);\theta’_t)$ 可以看到区别就是对于targetQ构建的时候 next state的action和q值选取还是不一样的」

对于这个的实现 显然我们需要建立两个网络,两个网络有着相同的结构 只是target网络不需要独立的来更新,只需要在主网络更新后,一定次数后 复制主网络的参数到主网络就好,具体操作如下:

#网络结构
self.scalarInput=tf.placeholder(shape=[None,21168],dtype=tf.float32)
self.imageIn=tf.reshape(self.scalarInput,shape=[-1,84,84,3])
with tf.name_scope("conv_layers"):
    self.conv1= slim.conv2d( \
                inputs=self.imageIn,num_outputs=32,kernel_size=[8,8],stride=[4,4],padding='VALID', biases_initializer=None)
    self.conv2 = slim.conv2d( \
                inputs=self.conv1,num_outputs=64,kernel_size=[4,4],stride=[2,2],padding='VALID', biases_initializer=None)
    self.conv3 = slim.conv2d( \
                inputs=self.conv2,num_outputs=64,kernel_size=[3,3],stride=[1,1],padding='VALID', biases_initializer=None)
    self.conv4 = slim.conv2d( \
                inputs=self.conv3,num_outputs=h_size,kernel_size=[7,7],stride=[1,1],padding='VALID', biases_initializer=None)
flatten_layer=slim.flatten(self.conv4)
with tf.name_scope("fcn"):
    self.Qout=slim.fully_connected(inputs=flatten_layer,num_outputs=env.actions                                     ,activation_fn=None)
self.predict=tf.argmax(self.Qout,1)

关于target网络 结构什么的都和这个相同,用来上面所说的target值的生成用,同样的 现在的主网络的相关参数的更新也是:

self.targetQ= tf.placeholder(shape=[None],dtype=tf.float32)
self.actions = tf.placeholder(shape=[None],dtype=tf.int32)
self.actions_onehot = tf.one_hot(self.actions,env.actions,dtype=tf.float32)
        
self.Q=tf.reduce_sum(tf.multiply(self.Qout, self.actions_onehot), axis=1)
        
self.td_error= tf.square(self.targetQ - self.Q)
with tf.name_scope("loss"):
    self.loss = tf.reduce_mean(self.td_error)
            tf.summary.scalar("loss",self.loss)
with tf.name_scope("train"):
    self.trainer = tf.train.AdamOptimizer(learning_rate=0.0001)
    self.updateModel = self.trainer.minimize(self.loss)

这里的损失函数的构建 使用了targetQ和目前的Q的MSE,而targetQ需要用到target网络,因而我们需要在后面构建使用,这里只是用tf.placeholder来占个位置,具体的导入需要建立起来target网络 来得到具体的target值:

#基于DQN的想法 于是创建两个NN一个主网络用于选择动作,另一个目标网络用于计算target Q
mainQN=Qnetwork(h_size)
targetQN=Qnetwork(h_size)
"""
...
中间包括相关的与环境交互 得到交互数据放入经验池的操作,
是训练部分里面的操作
"""
trainBatch=myBuffer.sample(batch_size) #从总体的经验里面抽取 
#计算next_state的Q情况 依照qlearning 选取最大的Q
next_Q=sess.run(targetQN.Qout
        ,feed_dict={targetQN.scalarInput:np.vstack(trainBatch[:,3])})
max_next_Q=tf.reduce_max(next_Q,axis=1)
#计算target
targetQ=trainBatch[:,2]+(gamma*max_next_Q)

#更新主网络「其实里面应该过一段时间再更新」
_ = sess.run(mainQN.updateModel, \
                        feed_dict={mainQN.scalarInput:np.vstack(trainBatch[:,0])
                            ,mainQN.targetQ:targetQ, mainQN.actions:trainBatch[:,1]})
#进而更新target网络
trainable_variable=tf.trainable_variables()
targetOps=updateTargetGraph(trainable_variable,tau)#用来基于主网络的更新target网络
updateTarget(targetOps,sess)#运行

当然 真正按照算法 也不是在更新完主网络后,就直接的更新target网络,参考上面算法描述,需要设置一个参数N 主网络更新N步之后才更新target网络 这里使用了指数衰减平均的操作 参考后面; 同样 这里的target网络更新方法 也不是像是主网络那样优化更新,而是从主网络拷贝参数而来,如下:

#首先是操作的设置
def updateTargetGraph(tfVars,tau):
# 利用主网络参数更新目标网络
    total_vars=len(tfVars)
    op_holder=[]
    for idx,var in enumerate(tfVars[0:total_vars//2]):
        op_holder.append(tfVars[idx+total_vars//2].assign(
            (var.value()*tau)+((1-tau)*tfVars[idx+total_vars//2].value())))
        return op_holder
#然后是操作的运行
def updateTarget(op_holder,sess):
    for op in op_holder:
        sess.run(op)

联系上面的代码可以看到,赋值操作是:这里我们将整个graph的variable都取出来,因为两者网络结果一样,其实就是两套相似的参数,前者为主网络后者为target网络,要做的其实就是将后一套对应位置的参数 按照指数衰减平均的操作进行更新,也就是有:$TargetVariable=MainVariable \times \tau + (1-\tau)\times TargetVariable$
这样做的好处就是 每步参数的更新也都在其中有所体现,不像是直白的更新了多步后,硬性的赋值过去「好坏 我还真没对比(扭头)」

以上就是对于DQN主要的介绍,然后整体实现代码 可见这里

DQN的变形:DDQN和duiling DQN

DDQN

首先 我们要知道一个关于qlearning的问题;作为off-policy的qlearning 其行动策略和评估策略是不同的策略,具体来说就是 在选取当前状态$s$的动作$a$的时候,基于ε-greedy来选择动作,而评估更新值函数的时候 采用贪婪策略来选取$s_{next}$的对应动作,也就是直接选取当前状态下对应有着最大值函数的一个。虽然说这样的离策略的操作 有着种种好处,参考上一篇的介绍:「能够获得子部分的最优性,在前面累积的最优化结果的基础上进行更新与优化」 但同样这样的最大化操作就打来的一个问题就是:估计的值函数是比实际的值函数要大的;当然 如果都一样过大的估计值函数 其实也不影响动作的选取;但 model-free都是基于环境采样来估计值函数的,也就是说 只是部分轨迹的值函数是被过估计的; 事实上 这个过估计的程度并不是均匀的在实际问题中,于是导致过估计成为了影响策略决策的问题所在,导致选取的action并不是最优 导致选取的不是最优策略; 进而说 对其改进就是double qlearning了,对于基于值函数近似「比如神经网络」来说 用式子表达就是$Y_t^Q=R+\gamma max_a Q(s_{t+1},a;\theta_t)$ ,此时无论是前期选取action还是后面构造TD目标 都是基于某一step的网络结构参数$\theta$ ,换句话说:选取action时候 需要先判断对于这一状态的全部action对应的Q值 这时候的需要的网络结构的参数θ;评价action时候 构造TD目标 也需要对应当前参数θ的输出g「一个包含对应S_t+1全部action对应Q的数组」根据上面动作选择的action 选择那个对应该action的Q值 然后再对应构造D目标$Y_t^Q$ ;

也就是说有:$Y_t^Q=R+\gamma Q(S_{t+1},argmax_aQ(S_{t+1},a;\theta_t);\theta_t)$

而对于double qlearning来说,这里的动作选取和动作评价就是不同的Q值函数了 也就是其中对应的θ是不一样的 于是有:$Y_t^{DoubleQ}=R+\gamma Q(S_{t+1},argmax_aQ(S_{t+1},a;\theta_t);\theta’_t)$;可以看到这时候关于动作的选取依旧是 基于当前值函数网络的参数$\theta_t$ 是基于online weight $\theta_t$ 选取出来那个对应最大Q值的action $a^*$ ;但是对于策略的评价 也就是这个action选取的评价 采取另一个weight $\theta’_t$ 来输出一个Q值来构造D目标$Y_t^Q$ ,可以让策略评价更为公正; 总结性的来说:Double DQN产生的直接原因是常规的DQN在给定状态下往往会高估具有潜力的行动。如果所有的行动总是被同样高估的,那么这个情况也不错,但是事实并非如此。你可以想象一个情形,次优的行动经常得到超过最优行动的Q值,此时agent将很难习得最优的策略。为了纠正这个错误,DDQN的作者使用了一个简单的技巧:利用主网络选择行动,目标网络来生成该行动的目标Q值,而不是在训练过程中计算目标Q值的同时选择最大Q值对应的行动。将行动选择从目标Q值生成逻辑中抽离出来后,网络高估行动的问题基本得到了解决,训练也更加快速和可信。 就实现来说,同样的两个网络结构,我们只需要在原有的结构上修改就好,可以看到它和DQN的区别 也主要在于DDQN里面选取东西是一个网络 而评价也就是对应的值函数的获取是在另一个网络的,而DQN在于评价过程中也就是关于next state的动作选择和值函数都是用到第二个网络的 而DDQN里面 对于next state的相关参数也就是target值的构建,选取动作还是基于原来的main 网络,而对应的值函数是基于target 网络;

具体来说代码如下:

trainBatch = myBuffer.sample(batch_size) # 从记录中随机获取训练批次数据
# 使用 Double-DQN 更新目标Q值
Q1 = sess.run(mainQN.predict,feed_dict={mainQN.scalarInput:np.vstack(trainBatch[:,3])})#输出基于主网络的动作选择「next state」

Q2 = sess.run(targetQN.Qout,feed_dict={targetQN.scalarInput:np.vstack(trainBatch[:,3])})#输出基于target网络的Q情况「next state」
end_multiplier = -(trainBatch[:,4] - 1)#判断是否到最终状态
doubleQ = Q2[range(batch_size),Q1]
targetQ = trainBatch[:,2] + (y*doubleQ * end_multiplier)
 _ = sess.run(mainQN.updateModel, \
                        feed_dict={mainQN.scalarInput:np.vstack(trainBatch[:,0]),mainQN.targetQ:targetQ, mainQN.actions:trainBatch[:,1]})

可以看到,和上面的DQN的区别也只是在于target值的构建,其他的都是一样的,这里完整版代码先不放,和后面的duiling DQN放在一起实现的一个版本;

Dueling DQN

相比于DQN来说,这种方法 更多的还是对于DQN网络结构的改进:在卷积层的输出上分为两路走即:the state value function 和 the state-dependent action advantage function. 好处:在对底层强化学习算法不发生改变的情况下 泛化学习过程中的action,换句话说 当有着很多相似值action的时候 有着更好的策略估计; 如上图所示的结构,可以看到中提出的dueling network 将后续的输出分为了两个分支,一条输出标量的关于状态的价值,另外一条输出关于动作的Advantage价值函数的值 ; 具体来说 就是:在评估Q (S,A)的时候也同时评估了跟动作选择无关的状态的价值函数V(S)和在状态下各个动作的相对价值函数A(S,A)的值 ;

而意义的话 参考下图 相比于DQN直接输出的是Q函数的值(action的维度 )需要评估各个action,可是对于大多数状态来说可能不必要评估全部的action; 从上图可以看到 图中是不同时间点下value和advantage所关注的图,可以看到在无论哪个时间点里面 value所关注的一直是路面或者说是其中的地平线horizon「也就是行进的路线」;而advantage不关注视觉的输入,只关注的是有没有车辆会导致发生碰撞,于是在上右图中 advantage没有关注的东西,在下右才会关注到车辆 因为会导致其发生碰撞;所以动作更受advantage的影响
采用advantage更为关注的是动作采取后场景的改变情况,对于action来说 其采取的意义也主要就是在对于状态的改变情况上,因而对于状态中情况 更多的还是关注动作所改变的哪些部分,因而借助于提取出来的advantage 来学习到这样一个相对价值函数A(S,A);「动作对于状态场景的改变量」 the dueling architecture 可以学习到哪一个状态是有价值的 (which states are (or are not) valuable),而不必学习每一个 action 对每一个 state 的影响。这个对于那些 actions 不影响环境的状态下特别有效 「针对那些action对于环境状态影响不是那么明显的状态」。 而实现来说就更为简单了,主要就是在对于DQN里面卷积层输出时候的改变,这个时候在卷积层之后 将网络层分为两部分

"""
卷积层的构建后
"""
# 取得最后一层卷积层的输出进行拆分,分别计算价值与决策
self.streamAC, self.streamVC = tf.split(self.conv4, 2, 3)
self.streamA = slim.flatten(self.streamAC)
self.streamV = slim.flatten(self.streamVC)
xavier_init = tf.contrib.layers.xavier_initializer()
self.Advantage=slim.fully_connected(inputs=self.streamA,num_outputs=env.actions
                                     ,activation_fn=None)
self.Value=slim.fully_connected(inputs=self.streamV,num_outputs=1
                                     ,activation_fn=None)
#当然 这里你写成:也是一样的作用 毕竟就是个全连接嘛 
#算算看卷积层输出的feature map大小刚好为:20 9 7 1 于是flatten后也就是512
#self.AW = tf.Variable(xavier_init([h_size//2, env.actions]))    
#self.VW = tf.Variable(xavier_init([h_size//2, 1]))                     
#self.Advantage = tf.matmul(self.streamA, self.AW)
#self.Value = tf.matmul(self.streamV, self.VW)

self.Qout=self.Value+tf.subtract(self.Advantage,tf.reduce_mean(self.Advantage,axis=1,keep_dims=True))

其他的操作就是DQN或者DDQN了,根据需要进行选择 比如这里的完整版代码就是一个Dueling-DDQN 以上。