深度Q网络(Deep Q Network,DQN)是一种将神经网络和Q-learning结合的方法 [3] 。传统的Q-learning用表格的方式来记录状态和动作对应的Q值的方法在处理一些大规模的问题上会占用极大的内存,比如使用Q-learning来记录一场围棋比赛中的状态数,可能存在的状态数是海量的,再使用一张二维的表格来存储状态和动作对应的Q值显然是不现实的,而且重复地在这么大的表格中进行搜索也是一件很耗时的事情。为了解决这个问题,我们将神经网络和Q-learning结合在一起,这样就没有必要再用一张表格来记录Q值,而是直接将状态作为神经网络的输入,用神经网络计算出所有的动作价值,并从其中选出一个最大值作为输出,或者将状态和动作都作为神经网络的输入,直接输出对应的Q值,这就是DQN。
然而,直接将两者结合起来会面临一些问题。首先,大多数DL都需要大量的手工标记的训练数据,然而RL算法处理的数据大部分会出现稀疏、噪声甚至延迟等情况;另一个问题是DL处理的数据一般会要求相互独立,然而在RL中,通常会遇到高度相关状态的序列。此外,在RL中,数据分布会随着算法学习新行为而改变,而DL中假设数据基础分布是固定的,这方面也可能会存在问题。
针对以上问题,DQN采用了行为和观察值的序列作为学习的样本,由于这样的序列彼此之间是完全不同的,所以用这样的序列作为RL中的状态时,所有的状态都是完全不同的值,这样就可以将问题转化为MDP,也就方便了使用RL来解决问题。
此外,DQN还有一个非常重要的特点是,它拥有一个经验复用池(experience replaybuffer)来学习之前的学习经历,其中存储的“学习经历”就是上面提到的行为和观察值的序列,这样在每次DQN更新的时候,可以随机抽取一些之前的学习经历进行学习。随机抽取的做法打乱了学习经历之间的相关性,也使得神经网络的更新更有效率。
和Q-learning类似,DQN的目标是最大化未来的奖励,假设未来奖励每一时间步长的折扣因子为γ,那么如果游戏在时间T时停止,则在时间t时的未来折扣奖励为R
t
=
,定义动作价值函数Q
*
(s,a)为所有动作价值中的最大值,则Q
*
(s,a)=max
π
E[R
t
|s
t
=s,a
t
=a,π),其中π是将状态序列映射到动作(或动作分布)的策略。
如果对于下一个状态s'所有可能存在的动作a',对应的最大值Q * (s',a')都已知的话,那么当前的最优策略就是选择一个使目标函数r+γQ * (s',a')最大的动作a',
许多RL算法的基本思想都是通过使用Bellman方程作为迭代更新来估计动作价值函数Q
i+1
(s,a)=
,这种值迭代算法收敛于最优动作价值函数,即当i→∞时Q
i
→Q
*
,然而在实践中,这种方法是完全不切实际的,因为动作价值函数是针对每一个单独的序列来单独估计的。所以在实践中通常使用函数逼近器来估计动作价值函数Q(s,a;θ)≈Q
*
(s,a)。将具有权重θ的神经网络函数逼近器称为Q网络。可以通过最小化在每次迭代i处改变的损失函数L
i
(θ
i
)来训练Q网络。
其中y
i
=
是迭代i的目标,p(s,a)称为动作分布(behaviour distribution),是序列s和动作a对应的概率分布。当优化损失函数L
i
(θ
i
)时,暂时固定前一次迭代的参数θ
i-1
,从而得到损失函数的梯度计算公式如下:
在实际计算中,一般会通过随机梯度下降来优化损失函数。如果每一步之后都更新权重,并用单一样本来代替期望的话,这样得到的就是我们熟悉的Q-learning算法。
以下是DQN的伪代码。
算法:DQN
对比Q-learning的伪代码可以发现,DQN的整体框架和Q-learning是类似的,区别在于DQN在Q-learning的基础上添加了一些新的元素:神经网络和经验复用池。其中,神经网络的作用是接收当前的状态,然后计算出所有动作的价值,并选择一个最大的作为输出,需要注意的是,因为每一步参数更新时依赖的是Q现实值和预测的Q估计值之间的差距,也就是说当前参数会影响下一步进行参数训练的数据样本,比如如果最大行为价值对应的行为是向左移动,那么训练样本将受到左侧的数据样本的影响,这种情况就可能导致回路的出现,使神经网络的训练参数陷入一个局部的最小值中,甚至可能导致训练不收敛。因此DQN采用了两个神经网络,即在线网络(online network)和目标网络(target network),其中在线网络不停地更新参数,用来进行神经网络的训练,计算出Q估计;而目标网络则冻结参数,隔一段时间更新一次,用来计算Q现实值。经验复用池用来存储之前学习过程中每一步的样本,并在之后的学习过程中随机从之前的样本中抽取一些样本来进行学习。“暂时冻结神经网络参数”和“经验复用池”这两个机制很好地解决了上面描述的神经网络和RL结合时可能出现的问题。
DQN的训练流程如图2.1所示。目标网络Q(s,a|θ
i
)与在线网络
结构相同,只是在每N步后对目标网络进行参数更新,使得
=θ
i
。在一段时间内目标Q值是保持不变的,一定程度上降低了当前Q值和目标Q值之间的相关性,提升了算法的稳定性。
在每个时间步t,将智能体与环境交互得到的状态转移序列e t =(s t ,a t ,r t ,s t+1 )存储到经验复用池D={e 1 ,e 2 ,…,e t }中。每次训练时,从D中随机抽取小批量的样本,并使用随机梯度下降算法更新网络参数θ。在训练深度网络时,通常要求样本之间是相互独立的,这种随机采样的方式,大大降低了样本之间的相关性,同样也使得算法更加稳定。
下面给出使用TensorFlow实现的DQN的部分代码解析。
1)训练网络
1. while True: 2. env.render() 3. action = RL.choose_action(observation) 4. observation_, reward, done = env.step(action) 5. RL.store_transition(observation, action, reward, observation_) 6. if (step > x) and (step % y == 0): 7. RL.learn() 8. observation = observation_ 9. if done: 10. break 11. step += 1
这段代码是整个算法的训练框架。在每次训练时,首先根据目前的观测值observation选择一个动作action,然后将该动作施加到环境当中去,得到下一个观测状态observation_、奖励值reward以及这一步是否结束的标记done,接着将当前观测状态、动作、奖励值、下一个观测状态作为一个样本存储在经验复用池中,最后将下一步的观测状态赋值给当前的观察状态。当复用池中至少有x个样本,也就是说至少运行了x步之后才开始学习,过了x步之后每隔y步学习一次。
图2.1 DQN训练流程
2)更新网络参数
1. def choose_action(self, observation): 2. observation = observation[np.newaxis, :] 3. 4. if np.random.uniform() < self.epsilon: 5. actions_value = self.sess.run(self.q_eval, feed_dict = {self.s: observation}) 6. action = np.argmax(actions_value) 7. else: 8. action = np.random.randint(0, self.n_actions) 9. return action
这段代码是进行动作决策的代码。在选择动作时,将观测状态observation放入神经网络,并输出所有的动作价值,然后以ε的概率选择输出最大价值的动作,否则选择一个随机动作。
1. def store_transition(self, s, a, r, s_): 2. if not hasattr(self, 'memory_counter'): 3. self.memory_counter = 0 4. transition = np.hstack((s, [a, r], s_)) 5. index = self.memory_counter % self.memory_size 6. self.memory[index, :] = transition 7. self.memory_counter += 1
这段代码将样本存储在经验复用池中,每一个样本的标签index=当前样本的个数(self.memory_counter)%经验复用池的大小(self.memory_size),也就是说,当样本的数量超过复用池大小的时候则从复用池的顶部开始覆盖。
1. def learn(self): 2. ... 3. q_target = q_eval.copy() 4. 5. batch_index = np.arange(self.batch_size, dtype = np.int32) 6. eval_act_index = batch_memory[:, self.n_features].astype(int) 7. reward = batch_memory[:, self.n_features + 1] 8. 9. q_target[batch_index, eval_act_index] = reward + self.gamma * np.max(q_next, axis = 1) 10. _, self.cost = self.sess.run([self._train_op, self.loss], feed_dict = {self.s: batch_memory[:, :self.n_features], self.q_target: q_target}) 11. self.cost_his.append(self.cost)
这段代码获取了目标网络产生的Q值和在线网络产生的Q值,并用这两个值来训练online network。其中q_next、q_eval包含了所有动作的值,而需要的只是已经选择好的动作的值,其他并不需要,所以将其他的动作值全都变成0,将需要用到的动作的误差值反向传递回去,作为梯度更新,这就是最终想要达到的样子。
1. def _build_net(self): 2. # ------------------ all inputs ------------------------ 3. self.s = tf.placeholder(tf.float32, [None, self.n_features], name = 's') # input State 4. self.s_ = tf.placeholder(tf.float32, [None, self.n_features], name = 's_') # input Next State 5. self.r = tf.placeholder(tf.float32, [None,], name = 'r') # input Reward 6. self.a = tf.placeholder(tf.int32, [None,], name = 'a') # input Action 7. 8. w_initializer, b_initializer = tf.random_normal_initializer(0., 0.3), tf.constant_initializer(0.1) 9. 10. # ------------------ build evaluate_net ------------------ 11. with tf.variable_scope('online_net'): 12. e1 = tf.layers.dense(self.s, 20, tf.nn.relu, kernel_initializer = w_initializer, bias_initializer = b_initializer, name = 'e1') 13. self.q_eval = tf.layers.dense(e1, self.n_actions, kernel_initializer = w_initializer, bias_initializer = b_initializer, name = 'q') 14. 15. # ------------------ build target_net ------------------ 16. with tf.variable_scope('target_net'): 17. t1 = tf.layers.dense(self.s_, 20, tf.nn.relu, kernel_initializer = w_initializer, bias_initializer = b_initializer, name = 't1') 18. self.q_next = tf.layers.dense(t1, self.n_actions, kernel_initializer = w_initializer, bias_initializer = b_initializer, name = 't2') 19. 20. with tf.variable_scope('q_target'): 21. q_target = self.r + self.gamma * tf.reduce_max(self.q_next, axis = 1, name = 'Qmax_s_') # shape = (None,) 22. self.q_target = tf.stop_gradient(q_target) 23. with tf.variable_scope('q_eval'): 24. a_indices = tf.stack([tf.range(tf.shape(self.a)[0], dtype = tf.int32), self.a], axis = 1) 25. self.q_eval_wrt_a = tf.gather_nd(params = self.q_eval, indices = a_indices) # shape = (None,) 26. with tf.variable_scope('loss'): 27. self.loss = tf.reduce_mean(tf.squared_difference(self.q_target, self.q_eval_wrt_a, name = 'TD_error')) 28. with tf.variable_scope('train'): 29. self._train_op = tf.train.RMSPropOptimizer(self.lr).minimize(self.loss)
这段代码就是搭建神经网络的代码,它搭建了两个结构完全一样的神经网络,其中online network的参数随着训练不停地更新,target network是online network的一个历史版本,拥有online network很久之前的一组参数,而且这组参数被暂时固定,训练一定次数之后再用online network的新参数来进行替换,而online network是在不断被提升的,所以是一个可以被训练的神经网络。
与传统的Q-learning相比,DQN存在几个优点:首先,DQN的复用池使更新权重的时候每一个样本都有可能被抽取到,提高了数据的利用效率;其次,由于样本之间存在强相关性,直接抽取连续的样本是得不到良好学习效果的,而这样随机地从复用池中抽取样本的做法打乱了样本之间的相关性,从而提高了学习的效率;当进行在线学习时,当前的参数会决定下一个进行参数训练的数据样本,比如如果最大动作价值对应的动作是向左移动,那么训练样本将受到左侧的数据样本的影响,这种情况就可能导致回路的出现,使神经网络的训练参数陷入一个局部的最小值中,甚至可能导致灾难性的分歧,而使用复用池则很完美地解决了这个问题,每次学习时随机抽取样本会使学习过程更加平滑,避免了参数振荡或发散。
由之前的Q-learning以及DQN的介绍可知,Q-learning在估计动作价值的时候包含了选取最大估计的步骤,所以在学习的过程中可能会导致过估计(overestimate),特别是结合了Q-learning和DL的DQN算法,在Atari 2600的游戏中存在大量的过估计。在有些情况下,这种过估计可能会产生正面的收益,但是如果这种过估计不均匀而且没有集中在想要了解的状态上,这种过估计就会产生负面的影响。
针对上面所说的过估计的情况,双Q学习的方法可以解决在Q-learning中可能存在的过估计,并把这个算法应用到DQN上,也就是深度双Q网络(Double Deep Q Network,DDQN) [4] ,它不仅可以减少过估计的问题,并且在有些游戏上也可以获得更好的性能。
标准的Q-learning使用
来选取和评估动作,这种选择方式就有可能会导致选择过高估计的动作价值,从而产生不利的价值估计。为了防止这种情况,双Q学习(double Q-learning)的基本思想是将选择与评估分开。在双Q学习中有两套值函数,每个学习经历都会随机分配给其中一个值函数来进行更新,这样就出现了两组权重集合θ和θ′,那么对于每次更新,其中一组权重用来决定贪心策略,另一组权重用来确定其值。传统的Q-learning更新时的目标Q现实值一般定义为:
在双Q学习中,为了进行清晰的比较,需要先把Q-learning中的选择和评估分开,也就是说,Q-learning的目标就可以重写为:
那么双Q学习的误差可以写为:
注意在argmax中动作的选择仍旧取决于在线的权重θ
t
。这表示,如Q-learning那样,仍然会根据当前值来估计贪心策略的值。这里,我们使用第二个权重集合
来公平地衡量这个策略的值。第二个权重集合可以通过更换θ和θ′的角色来对称地更新。
DDQN将双Q学习的想法应用到DQN上,用DQN中的两个神经网络(在线网络和目标网络)来描述。具体来说,就是用目标网络来估计目标方程中maxQ(s′,a′)的动作最大值,然后用这个估计出来的动作来选择在线网络中的Q(s′)。也就是说,DDQN利用了DQN中本来就有两个神经网络的天然优势,使用在线网络来评估贪心策略,使用目标网络来估算其价值。DDQN的整体结构与DQN相同,只是将目标价值的更新策略替换为公式(2.7)。
与双Q学习相比,第二个网络的权重
被替换成了
,用于评估当前的贪心策略,对目标网络的更新与DQN完全相同,仍然是在线网络的定期更新副本。
图2.2 DQN训练流程
DDQN的训练流程如图2.2所示。对比DQN的训练流程可以发现,DDQN只有在从目标网络和在线网络中取值时与DQN不同,其他的运行流程都是相同的。
以下是DDQN的伪代码。
算法:DDQN
由上面的描述可知,DDQN与DQN存在很大的相似性,所以这两者的代码在结构上是完全一样的,实现方法也都基本类似,直接在DQN的代码基础上进行修改就可以得到DDQN的代码。
下面是使用TensorFlow实现的DDQN算法。
1. class DoubleDQN: 2. def learn(self): 3. # 这一段和 DQN 一样 4. if self.learn_step_counter % self.replace_target_iter == 0: 5. self.sess.run(self.replace_target_op) 6. print('\ntarget_params_replaced\n') 7. 8. if self.memory_counter > self.memory_size: 9. sample_index = np.random.choice(self.memory_size, size = self.batch_size) 10. else: 11. sample_index = np.random.choice(self.memory_counter, size = self.batch_size) 12. batch_memory = self.memory[sample_index, :] 13. 14. # 这一段和 DQN 不一样 15. q_next, q_eval4next = self.sess.run( 16. [self.q_next, self.q_eval], 17. feed_dict = {self.s_: batch_memory[:, -self.n_features:], # next observation 18. self.s: batch_memory[:, -self.n_features:]}) # next observation 19. q_eval = self.sess.run(self.q_eval, {self.s: batch_memory[:, :self.n_features]}) 20. q_target = q_eval.copy() 21. batch_index = np.arange(self.batch_size, dtype = np.int32) 22. eval_act_index = batch_memory[:, self.n_features].astype(int) 23. reward = batch_memory[:, self.n_features + 1] 24. 25. if self.double_q: # 如果是 DDQN 26. max_act4next = np.argmax(q_eval4next, axis = 1) # q_eval 得出的最高奖励动作 27. selected_q_next = q_next[batch_index, max_act4next] # DDQN 选择 q_next 依据 q_eval 选出的动作 28. else: # 如果是 Natural DQN 29. selected_q_next = np.max(q_next, axis = 1) # natural DQN 30. 31. q_target[batch_index, eval_act_index] = reward + self.gamma * selected_q_next 32. 33. 34. # 下面和 DQN 一样 35. _, self.cost = self.sess.run([self._train_op, self.loss], 36. feed_dict = {self.s: batch_memory[:, :self.n_features], 37. self.q_target: q_target}) 38. self.cost_his.append(self.cost) 39. self.epsilon = self.epsilon + self.epsilon_increment if self.epsilon < self.epsilon_max else self.epsilon_max 40. self.learn_step_counter += 1
由于DDQN与DQN只有在计算目标Q值的部分有差别,所以重点分析两者的差别部分,首先得到q_next、q_eval4next和q_eval三个值,其中q_next、q_eval4next这两个值的输入都是下一个观测值s t+1 ,但是分别使用了目标网络和在线网络,q_next是目标网络计算出来的Q现实值,q_eval4next是使用在线网络估计出来的Q现实值,而q_eval是当前这一步的神经网络的输出值,也就是Q估计值。计算出来这三个值以后,如果是普通的DQN,直接选取q_next中的最大值即可,而如果是DDQN的话,则需要先从q_eval4next中选出一个最大的,记录这个最大值的索引,然后从q_next中选择对应索引的值,这个值就是DDQN对应的最优值。
传统的DQN在学习过程中会存在过估计的现象,而且这种过估计现象有可能会导致负面的学习效果,而DDQN则很好地解决了这个过估计的问题,而且DDQN使用的是DQN现有的架构和神经网络,不需要额外的参数,很方便实现。
竞争网络架构(Dueling DQN)是针对DQN的另一种改进 [5] ,它对DQN中神经网络的架构进行了简单的修改,但是大幅提升了学习的效果。Dueling DQN在评估某个状态下动作的价值Q(s,a)的时候也同时评估了跟动作无关的状态的价值函数V(s)和在该状态下各个动作的相对价值函数A(s,a)的值,如图2.3所示。
图2.3中上图是传统的DQN算法的网络图,下图是Dueling DQN的网络图,从图中可以看到,DQN的神经网络直接输出Q函数的值,即某个状态下动作对应的价值,它的前一层是全连接层。Dueling DQN针对DQN的改进主要就在全连接层,它将全连接改成两条流,其中一条输出关于状态的价值,另外一条输出关于动作的优势函数(advantage function)的值,最终合并为Q价值函数,如图2.3中虚线框所示。用一句话来概括就是,Dueling DQN将每个动作的Q拆分成了状态的价值加上每个动作的Advantage。
下面用一个简单的例子来进行解释。如图2.4所示,这是一个开车的游戏,左边白晕色区域的分布更关心状态,右边则更关心Advantage,白晕的部分表示小车运行过程中关心的部分,从上半部分可以看到,因为小车附近没有其他障碍物,所以小车的运行更关心前面的路线,而不关心究竟采取什么动作;下半部分中小车附近出现了障碍物,右图中的Advantage就更关心附近的障碍物,此时的动作就会受到Advantage的影响。
图2.3 传统DQN与Dueling DQN的网络图对比
图2.4 Value与Advantage对比
Dueling DQN中每个动作的Q值由下面的公式确定:
从这个公式中也能看出Dueling DQN的Q函数是由状态s的价值函数V(s)加上每个动作的优势函数A(s,a)得到的,其中V(s)这个价值函数表明了状态的好坏程度,优势函数表明在这个状态下某一个动作相对于其他动作的好坏程度,而Q函数则表明了这个状态下确定的某个动作的价值。
但是由这个公式来确定Q值还会存在一个问题,因为V(s)是一个标量,所以在神经网络中,这个值不管左偏还是右偏,对最后的Q的值是没有影响的。也就是说,如果给定一个Q值,是无法得到唯一的V和A的。为了解决这个问题,我们可以强制令所选择的贪心动作的优势函数为0,即
这样就能得到唯一的值函数:
而另一种更为常用的改进方法则是使用优势函数的平均值来代替上述的最优值:
采用这种方法,虽然使得值函数V和优势函数A在语义上并不会完美地表示值函数和优势函数,但是这种操作提升了稳定性,而且并没有改变值函数和优势函数的本质表示。
下面是使用TensorFlow实现的Dueling DQN算法。
1. class DuelingDQN: 2. def __init__(..., dueling = True, sess = None) 3. ... 4. self.dueling = dueling 5. ... 6. if sess is None: 7. self.sess = tf.Session() 8. self.sess.run(tf.global_variables_initializer()) 9. else: 10. self.sess = sess 11. ...
初始化建立两个DQN,其中一个是Dueling DQN,另一个是传统的DQN,以便进行对比,并且针对两种DQN模式修改了tf.Session()的建立方式。
1. def _build_net(self): 2. def build_layers(s, c_names, n_l1, w_initializer, b_initializer): 3. with tf.variable_scope('l1'): 4. w1 = tf.get_variable('w1', [self.n_features, n_l1], initializer = w_initializer, collections = c_names) 5. b1 = tf.get_variable('b1', [1, n_l1], initializer = b_initializer, collections = c_names) 6. l1 = tf.nn.relu(tf.matmul(s, w1) + b1) 7. 8. if self.dueling: 9. # Dueling DQN 10. with tf.variable_scope('Value'): # 专门分析 state 的 Value 11. w2 = tf.get_variable('w2', [n_l1, 1], initializer = w_initializer, collections = c_names) 12. b2 = tf.get_variable('b2', [1, 1], initializer = b_initializer, collections = c_names) 13. self.V = tf.matmul(l1, w2) + b2 14. 15. with tf.variable_scope('Advantage'): # 专门分析每种动作的 Advantage 16. w2 = tf.get_variable('w2', [n_l1, self.n_actions], initializer = w_initializer, collections = c_names) 17. b2 = tf.get_variable('b2', [1, self.n_actions], initializer = b_initializer, collections = c_names) 18. self.A = tf.matmul(l1, w2) + b2 19. 20. with tf.variable_scope('Q'): 21. out = self.V + (self.A - tf.reduce_mean(self.A, axis = 1, keep_dims = True)) 22. else: 23. with tf.variable_scope('Q'): # 普通的 DQN 第二层 24. w2 = tf.get_variable('w2', [n_l1, self.n_actions], initializer = w_initializer, collections = c_names) 25. b2 = tf.get_variable('b2', [1, self.n_actions], initializer = b_initializer, collections = c_names) 26. out = tf.matmul(l1, w2) + b2 27. 28. return out
相对于传统的DQN,Dueling DQN只在神经网络的建立过程中不同,这在上面的代码中已经体现出来了。两种DQN的神经网络的第一层都是一样的,只在第二层有区别,根据Dueling DQN的网络结构,将第二层拆分成一个Value层和一个Advantage层,这两个层分别输出状态的价值Value和每个动作的优势Advantage,然后根据Q=V(s)+A(s,a)将这两个层合并输出得到每个动作的Q值。需要注意的是,在合并输出的时候需要减去优势函数的平均值,这样给定一个Q值,就能得到唯一的V和A。
在一般的游戏场景中,经常会存在很多状态,不管采用什么样的动作都对下一步的状态转变没什么影响,这些情况下计算动作的价值函数的意义就没有计算状态函数的意义大,在频繁出现智能体采取不同动作但对应值函数相等的情况下,Dueling DQN的优势会非常明显。
平均值DQN(Averaged-DQN)是基于传统DQN的一个简单但非常有效的改进 [6] ,它基于对先前学习过程中的Q值估计进行平均,通过减少目标价值中的近似误差方差,使训练过程更加稳定,并提高性能。
传统的DQN在学习过程中的得分数会偶尔突然下降,然后在下一个评估阶段恢复,而平均值DQN则很好地解决了这个问题,它使用之前的K个学习过程中的Q值估计值来生成当前的动作价值估计,平均值DQN的训练过程更加稳定,而且得分更高,性能更好。
平均值DQN主要关注的是传统DQN在学习过程中存在的误差,并想办法减少这些误差。设Q(s,a;θ i )为DQN的第i次迭代时的值函数,Δ i =Q(s,a;θ i )-Q * (s,a),并将其拆解如下:
其中
是估计值,
是现实值:
定义
为目标近似误差(Target Approximation Error,TAE),
为过估计误差,则:
其中,最优差额可以被看作是标准的表格型Q-learning中的误差,而过估计误差则是2.1.2节中讨论过的,由于传统的DQN在选取目标价值时总会采取选择最大值的操作,因此容易导致过估计。接下来对TAE进行详细讨论。
TAE(
)是在最小化DQN的损失后(即下面的伪代码中第7行的处理过程)Q函数中与估计值
之间的一个误差。TAE可能由多种因素导致,例如:由于不精确的最小化而导致次优参数θ
i
,由于神经网络的表现能力有限(模型误差),由于经验复用池的大小有限导致未知“状态-动作”对的泛化误差。
TAE可能导致策略偏移到更糟糕的结果,例如,当前对次优策略的偏移发生在
,则
因此猜测DQN所表现的可变性与TAE导致的稳态策略的偏离有关。
那么,该如何减少TAE的方差呢?假设TAE(
)是一个随机的过程,即
=0,
,并且对于i≠j,
=0。此外,为了只关注TAE,一般的做法是通过考虑更新目标价值的固定策略来消除过估计误差。为了方便,考虑设置奖励r=0,因为奖励对方差计算没有影响。
用
来表示第i次迭代中的估计值向量,Z
i
表示对应的TAE,那么对于平均值DQN,能得到:
其中
是给定策略的转移概率矩阵。为了能够明确地进行比较,进一步将模型转化为一个M状态的单向MDP,如图2.5所示。
图2.5 M状态单向MDP
考虑上面的单向MDP,其中状态从s 0 开始,s M-1 是终态,并且每个状态都是零收益,也就是说所有状态下的奖励都等于0。那么,将平均值DQN应用到这个MDP中,可以得到:
对于i>KM,
其中,
,
表示矩形脉冲(rectangle pulse)的离散傅里叶变换,并且|U
n
/K|≤1。
此外,对于K>1,m>0,有D K,m <1/K,因此有:
这也就意味着平均值DQN理论上能够有效地减少TAE的方差,并且至少要比普通的DQN好K倍。
以下是平均值DQN的伪代码。
算法:平均值DQN
对比传统的DQN,由算法第5行可以看出平均值DQN使用之前的K个学习过程中的Q值估计值来生成当前的动作价值估计,平均值DQN算法通过减少TAE的方差来稳定训练过程。与传统的DQN相比,平均值DQN主要的计算工作量是在将DQN的损失最小化的同时,通过Q网络向前传递的次数增加了K倍。计算过程中最需要的元素——反向传播更新的数量与DQN中的相同,算法输出的是上次学习的Q网络的平均值。
下面是使用PyTorch实现的平均值DQN算法。
1. q_a_values = q_values(obs_t_batch).gather(1, act_batch.unsqueeze(1)) 2. 3. q_a_values_sum = torch.FloatTensor(batch_size, num_actions).zero_() 4. q_a_values_sum = q_a_values_sum.cuda() 5. 6. for i in range(num_active_target): 7. q_a_values_sum = torch.add(q_a_values_sum, target_q_values[i](obs_tp1_batch).data) 8. 9. q_a_values_sum = Variable(q_a_values_sum) 10. q_a_vales_tp1 = q_a_values_sum.detach().max(1)[0] 11. 12. target_values = rew_batch + (gamma / num_active_target * (1-done_mask) * q_a_vales_tp1) 13. loss = (target_values - q_a_values).pow(2).sum() 14. if t % LOG_EVERY_N_STEPS == 0: 15. print "loss at {} : {}".format(t, loss.data[0]) 16. optimizer.zero_grad() 17. loss.backward() 18. optimizer.step() 19. num_param_updates += 1
这段代码是模型训练的过程,计算出目标价值target_values=rew_batch+(gamma/num_active_target*(1-done_mask)*q_a_vales_tp1),以及DQN的损失loss=(target_values-q_a_values).pow(2).sum()。
1. if t % target_update_freq == 0: 2. num_active_target += 1 3. if num_active_target >= num_target_values: 4. num_active_target = num_target_values 5. print "Update Q Values : Active {} Q values".format(num_active_target) 6. for i in range(num_active_target-1, 0, -1): 7. target_q_values[i].load_state_dict(target_q_values[i-1].state_dict()) 8. target_q_values[0].load_state_dict(q_values.state_dict())
这段代码是目标网络的更新过程,为了计算之前目标价值的平均值,每次更新的时候都将参与计算平均值的目标价值个数+1,并保证当前参与计算平均值的目标价值个数不超过num_target_values,其中num_target_values是一个提前设定好的常数(即前面伪代码中的K)。然后将新的num_active_target这么多的目标价值参与更新。
传统的DQN在学习的过程中可能会出现学习过程不稳定等缺陷,而平均值DQN通过计算之前学习的估计值的平均值来生成当前的动作价值估计,很好地解决了学习过程不稳定的问题,并且提高了DQN的学习性能,使得学习后的智能体得分更高。
前面介绍的很多针对DQN的改进方法都是在最原始的DQN基础上进行的单个方面改进,而Rai nbow [7] 则结合了DQN算法的6个扩展改进,将它们集成在同一个智能体上,其中包括DDQN、基于优先级的复用池(prioritized replay)、竞争网络(dueling network)、多步学习(multi-step learning)、分布式RL(distributional RL)和噪声网络(Noisy Net)。下面分别对这些改进进行简单的介绍,并说明Rainbow的智能体是如何将这些改进结合在一起的。
DDQN:在传统的DQN中,每次学习的时候都会使用当前策略认为的价值最高的动作,所以会出现对Q值的过高估计,而这个问题表现在神经网络上就是因为选择下一时刻的动作以及计算下一时刻Q值的时候都使用了目标网络。为了将动作选择和价值估计进行解耦,因此有了DDQN方法。在DDQN中,在计算Q实际值时,动作选择由在线网络得到,而价值估计由目标网络得到。
竞争网络: 将Q值函数分解为价值函数V和优势函数A的和,即Q=V+A,其中V这个价值函数表明了状态的好坏程度,优势函数表明在这个状态下某一个动作相对于其他动作的好坏程度,而Q函数则表明了这个状态下确定的某个动作的价值。然而,因为V是一个标量,所以一般来说,对于一个确定的Q,有无数种V和A的组合可以得到Q,因此需要对A进行一些限定,通常将统一状态下的优势函数A的均值限制为0,因此得到的Q值计算公式如下:
基于优先级的复用池: 将经验池中的经验按照优先级进行采样,在传统DQN的经验复用池中,选择batch的数据进行训练是随机的,没有考虑样本的优先级关系。但其实不同样本的价值是不同的,需要给每个样本一个优先级,并根据样本的优先级进行采样。
如何确定样本的优先级?可以用到TD误差,设目标网络产生的Q值为Q现实值,在线网络产生的Q值为Q估计值,那么TD误差也就是Q现实值-Q估计值,TD误差用来规定优先学习的程度。如果TD误差越大,就代表预测精度还有很多上升空间,那么这个样本就越需要被学习,即优先级p越高。
有了TD误差就有了优先级p,那么如何有效地根据p来抽样呢?最简单的方法就是在每次抽样的时候都针对p进行一次排序,然后选取里面的最大值。但是这样会浪费大量的计算资源,从而使训练的时间变长,较为常用的解决方法是使用一种SumTree方法来进行抽样。
SumTree是一种树形结构,叶子节点存储每个样本的优先级p,每个树枝节点只有两个分叉,节点的值是两个分叉的和,所以SumTree的顶端就是所有p的和,如图2.6所示,最下面一层树叶存储样本的p,叶子上一层最左边的节点13=3+10,按这个规律相加,顶层的根节点就是全部p的和。
图2.6 SumTree的结构
在抽样选择的时候,会把全部p的和分成批量大小(batch size)个区间,然后在每个区间里随机选取一个数,比如在区间[21-28]里选了24,就按照这个24从最顶上的42开始向下搜索。首先看到顶点42下面有两个子节点,拿着手中的24对比左子节点,因为29>24,那就走左边这条路;接着再对比29的左子节点13,因为24>13,那就走右边的路,并且将手中的值根据13修改一下,变成24-13=11。接着拿着11和13的左子节点12比,结果12比11大,那就选择12对应的数据作为该区间采样的结果。
多步学习: 传统DQN使用当前的即时奖励和下一时刻的价值估计来判断目标的价值,然而这种方法在训练的前期网络参数的偏差较大时会导致得到的目标价值也会偏大,进而导致目标价值的估计偏差较大,因此出现了多步学习来解决这个问题。在多步学习中,即时奖励会通过与环境交互准确得到,所以训练前期的目标价值可以得到更准确的估计,从而加快训练的速度。
分布式RL: 在传统的DQN中,一般网络输出的都是“状态-动作”对应的价值Q的期望估计值。单纯的期望值其实忽略了很多信息,假设在同一状态下的两个动作能够获得的价值期望相同,比如都是20,第一个动作是90%的情况下价值为10,10%的情况下价值为110,第二个动作是50%的情况下价值是25,50%的情况下价值为15,那么虽然这两个动作的期望是一样的,但是如果想要减少风险,就应该选择后一种动作。而如果网络只输出期望值的话,我们是看不到动作背后所隐含的风险的。
所以,如果从分布视角(distributional perspective)来建模DRL模型,就可以得到更多有用的信息,从而得到更好、更稳定的结果。选择用直方图来表示对于价值分布的估计,并将价值限定在[V min ,V max ]之间,在[V min ,V max ]选择N个等距的价值采样点,通过神经网络输出这N个价值采样点的概率,通过在线网络和目标网络,会得到估计的价值分布和目标的价值分布,然后使用交叉熵损失函数来计算两个分布之间的差距,并通过梯度下降方法进行参数的更新。
噪声网络: RL过程中总会想办法增加智能体的探索能力,传统的DQN通常会采取ε-greedy的策略,即以ε的概率采取随机的动作,以1-ε的概率来采取当前价值最大的动作。而另外一种常用的方法就是噪声网络,即通过对参数增加噪声来增强模型的探索能力。一般噪声会添加在全连接层,考虑全连接层的前向计算公式:
假设两层神经元的个数分别是p和q,那么w是q×p维的,x是p维的,y和b都是q维的,此时在参数上添加噪声,假设参数b和w分别服从于均值为μ、方差为σ的正态分布,同时存在一定的随机噪声N,假设噪声服从标准正态分布,那么前向计算公式变为:
产生噪声的方法一般采用Factorised Gaussian noise,w和b的计算方法如下:
其中f函数为
使用这种方法,只需要p+q个噪声即可。
集成智能体: Rainbow就是将上面的所有组件都集成在一个单一的集成智能体中,首先,用一个多步变量替换掉一步的分布损失,根据累积的减少量来收缩价值分布,并将其转换为截断值来构造目标分布,将目标分布定义为:
那么由此产生的损失为:
其中,
z
是在z轴上的投影。
然后利用贪心的思想将多步分布损失与DDQN结合,在s
t+n
时,根据在线网络选择出引导动作
,并用目标网络对其进行评估。
在标准的比例优先复用机制中,绝对TD误差被用作确定转变的优先级,但是在Rainbow中,所有分布的Rainbow变量都会优先考虑KL损失导致的转变:
优先考虑KL损失在有噪声的随机环境下可能会更加稳定,因为即使回报不是确定性的,损失也会继续减少。
Rainbow神经网络的架构是一个能够返回分布的竞争网络架构,该网络有一个共享的表示方式f
ξ
(s),它会被输入到一个有N
atoms
个输出的价值流v
η
和一个有N
atoms
×N
actions
个输出的优势流中,并用
表示第i个atom时动作a对应的输出。就像Dueling DQN中一样,对每一个atom z
i
,价值流和优势流最终的结果相加获得对应的Q函数,然后通过一个softmax层来获得归一化后的概率估计:
其中
=f
ξ
(s),
。
最后将上面描述的噪声网络添加到全连接层中。这样,集成了6个DQN改进的Rainbow的智能体就建立完成了。
下面给出使用PyTorch实现的Rainbow部分代码解析。
1. if T >= args.learn_start: 2. mem.priority_weight = min(mem.priority_weight + priority_weight_increase, 1) # Anneal importance sampling weight β to 1 3. 4. if T % args.replay_frequency == 0: 5. dqn.learn(mem) # Train with n-step distributional double-Q learning 6. 7. if T % args.evaluation_interval == 0: 8. dqn.eval() # Set DQN (online network) to evaluation mode 9. avg_reward, avg_Q = test(args, T, dqn, val_mem) # Test 10. log('T = ' + str(T) + ' / ' + str(args.T_max) + ' | Avg. reward: ' + str(avg_reward) + ' | Avg. Q: ' + str(avg_Q)) 11. dqn.train() # Set DQN (online network) back to training mode 12. 13. # Update target network 14. if T % args.target_update == 0: 15. dqn.update_target_net()
这段代码是Rainbow主函数中智能体运行的框架,运行步数每经过replay_frequency步则学习一次经验复用池中的数据,每经过evaluation_interval步则对online network进行一次训练,每经过target_update步则对target network进行一次更新。
1. def learn(self, mem): 2. # Sample transitions 3. idxs, states, actions, returns, next_states, nonterminals, weights = mem.sample(self.batch_size) 4. 5. # Calculate current state probabilities (online network noise already sampled) 6. log_ps = self.online_net(states, log = True) # Log probabilities log p(s_t, ·; θonline) 7. log_ps_a = log_ps[range(self.batch_size), actions] # log p(s_t, a_t; θonline) 8. 9. with torch.no_grad(): 10. # Calculate nth next state probabilities 11. pns = self.online_net(next_states) # Probabilities p(s_t+n, ·; θonline) 12. dns = self.support.expand_as(pns) * pns # Distribution d_t+n = (z, p(s_t+n, ·; θonline)) 13. argmax_indices_ns = dns.sum(2).argmax(1) # Perform argmax action selection using online network: argmax_a[(z, p(s_t+n, a; θonline))] 14. self.target_net.reset_noise() # Sample new target net noise 15. pns = self.target_net(next_states) # Probabilities p(s_t+n, ·; θtarget) 16. pns_a = pns[range(self.batch_size), argmax_indices_ns] # Double-Q probabilities p(s_t+n, argmax_a[(z, p(s_t+n, a; θonline))]; θtarget) 17. 18. # Compute Tz (Bellman operator T applied to z) 19. Tz = returns.unsqueeze(1) + nonterminals * (self.discount ** self.n) * self.support.unsqueeze(0) # Tz = R^n + (γ^n)z (accounting for terminal states) 20. Tz = Tz.clamp(min = self.Vmin, max = self.Vmax) # Clamp between supported values 21. # Compute L2 projection of Tz onto fixed support z 22. b = (Tz - self.Vmin) / self.delta_z # b = (Tz - Vmin) / Δz 23. l, u = b.floor().to(torch.int64), b.ceil().to(torch.int64) 24. # Fix disappearing probability mass when l = b = u (b is int) 25. l[(u > 0) * (l == u)] -= 1 26. u[(l < (self.atoms - 1)) * (l == u)] += 1 27. 28. # Distribute probability of Tz 29. m = states.new_zeros(self.batch_size, self.atoms) 30. offset = torch.linspace(0, ((self.batch_size - 1) * self.atoms), self.batch_size).unsqueeze(1).expand(self.batch_size, self.atoms).to(actions) 31. m.view(-1).index_add_(0, (l + offset).view(-1), (pns_a * (u.float() - b)).view(-1)) # m_l = m_l + p(s_t+n, a*)(u - b) 32. m.view(-1).index_add_(0, (u + offset).view(-1), (pns_a * (b - l.float())).view(-1)) # m_u = m_u + p(s_t+n, a*)(b - l) 33. 34. loss = -torch.sum(m * log_ps_a, 1) # Cross-entropy loss (minimises DKL(m||p(s_t, a_t))) 35. self.online_net.zero_grad() 36. (weights * loss).mean().backward() # Backpropagate importance-weighted minibatch loss 37. self.optimiser.step() 38. 39. mem.update_priorities(idxs, loss.detach()) # Update priorities of sampled transitions
上面的代码就是学习的过程,首先计算当前状态的概率分布,此时的online network已经用噪声处理过了,然后计算接下来的第n个状态的概率分布,其中pns_a=pns[range(self.batch_size),argmax_indices_ns]使用DDQN的方法,动作选择的概率分布pns由online network得到,而价值估计的概率分布pns_a由target network得到,然后计算KL损失loss=-torch.sum(m*log_ps_a,1),最后更新这条学习记录的优先级,回存到树形结构的经验复用池中。
1. class SegmentTree(): 2. def __init__(self, size): 3. self.index = 0 4. self.size = size 5. self.full = False # Used to track actual capacity 6. self.sum_tree = [0] * (2 * size - 1) # Initialise fixed size tree with all (priority) zeros 7. self.data = [None] * size # Wrap-around cyclic buffer 8. self.max = 1 # Initial max value to return (1 = 1^ω)
这是经验复用池的树形结构的初始化代码,其中,index表示每个节点的标签,size表示树中叶子节点的个数,full表示树形结构的叶子节点是否已经被填满,sum_tree表示树中所有节点的和,data表示叶子节点中的数据,max表示当前的所有叶子节点中的最大值。
1. class ReplayMemory(): 2. def __init__(self, args, capacity): 3. self.device = args.device 4. self.capacity = capacity 5. self.history = args.history_length 6. self.discount = args.discount 7. self.n = args.multi_step 8. self.priority_weight = args.priority_weight # Initial importance sampling weight β, annealed to 1 over course of training 9. self.priority_exponent = args.priority_exponent 10. self.t = 0 # Internal episode timestep counter 11. self.transitions = SegmentTree(capacity) # Store transitions in a wrap-around cyclic buffer within a sum tree for querying priorities
这是经验复用池的初始化代码,其中device表示当前所用设备,一般为CPU或者GPU,capacity为经验复用池的容量,history表示处理的连续状态数,discount表示折扣因子,n表示多步处理应该返回的步骤个数,priority_weight表示优先经验复用的权重,priority_exponent表示表示优先经验复用的指数,t用来进行步数统计,transitions即上面的树形结构,用来存储记忆。
Rainbow结合了6种DQN的改进,并将它们集成在同一个智能体中,因此相对于传统的DQN以及其他只进行单方面改进的DQN,Rainbow的训练效果有巨大进步,而且也能适用于各种场景。
对于RL的智能体来说,在每个状态下有很多可能存在的操作可以选择,甚至其中可能会存在很多操作是多余的或者不相关的,在这种情况下,有时候智能体更容易学会不采取什么行动。
面对这样的问题,一般会希望能将每个状态下的可用动作限制为最可能的动作的子集,因此很自然地会想到通过使用消除信号来消除行为的方法,具体地说,它向智能体提供采取的非最佳措施的即时反馈。在许多领域中,可以使用基于规则的系统来创建消除信号,例如,在基于解析器的文本游戏中,解析器在动作执行后会对不相关的动作给出反馈。在给定信号的情况下,可以训练一个机器学习的模型来预测它,然后用它来推广到未知状态。由于消除信号提供即时反馈,学习要消除的动作比仅使用奖励学习最佳动作的学习效率更快,因此,可以通过不频繁地探索无效动作来设计一种性能更好的算法。
更具体地说,基于动作排除的DQN(Action Elimination-Deep Q Network,AE-DQN)提出了一个学习Q函数近似值并同时学习消除动作的系统 [8] ,这个系统包含两个深度神经网络,即一个DQN和一个动作消除网络,两者都是使用适合自然语言处理任务的CNN设计,用动作消除网络消除无关动作,并允许DQN仅以有效的动作探索和学习Q值。
动作消除能够使智能体解决一些在大型动作空间中可能会遇见的问题,即函数近似(Function Approximation)和样本复杂性(Sample Complexity)。
函数近似
众所周知,Q函数中的错误可能会导致算法收敛到次优的策略,而这种现象在大动作空间中更加明显。动作消除可以通过只对有效动作使用max操作来减轻这种影响,从而减少潜在的过估计错误。消除动作的另一个好处是,Q估计值只需要对有效动作进行精确估计,这样的收益是两方面的:第一,函数收敛时就不需要对无效的操作进行采样;其次,函数近似可以学习一个更加简单的映射(例如只有有效动作的“状态-动作”对的Q值),因此可以通过忽略Q-learning中未探索的状态的错误来提高收敛的速率,更快地找到解决方案。
样本复杂性
MDP的样本复杂性衡量了学习过程中学习策略不是ε最优的步骤数量。假设现在有A'个动作需要被消除,并且它们是ε最优,设它们的价值至少为V * (s)-=ε,那么对于每一个“状态-动作”对,至少需要ε -2 (1-γ) -3 log1/δ个样本以1-δ的概率收敛。如果被消除的动作没有返回奖励,并且不会改变状态,那么动作差距就是ε=(1-γ)V * (s),这也就意味着,每学习一个有效的“状态-动作”对,就会有V * (s) -2 (1-γ) -5 log1/δ个浪费的样本,在γ的值较大时,将会产生大量浪费的样本。而消除算法可以更快地消除这些无效动作,因此可以加快学习的过程。
设x(s
t
)∈R
d
为状态s
t
的特征表示,假设在这种表示下存在一组参数
∈R
d
,使得状态s
t
的消除信号为e
t
(s
t
,a)=
,其中
。放宽之前的假设,允许消除信号拥有值E[e
t
(s
t
,a)],且对于任意一个无效动作都满足0≤E[e
t
(s
t
,a)]≤
。用X
(t,a)
(E
(t,a)
)表示矩阵(向量),其中的行(元素)为当前的观察状态,且在该状态下选择动作a。举个例子,在X
(t,a)
中的第i行就是在动作a被选择的情况下第i个状态的向量表示。
对于任何一个历史状态,并对于所有t>0,有至少1-δ的概率有
,其中
,如果对于任意的s,有
≤L,则β
t
可以被限定为
。接下来,定义
=δ/k,并对所有的动作都用这个概率限制,那么对于任意的a,t>0:
注意到在状态s下的任意有效动作a都满足
,因此,可以消除满足以下条件的动作:
这就能保证在概率为1-δ的情况下,不会消除有效的动作。
算法:AE-DQN
算法将动作消除结合到DQN算法中,生成了AE-DQN,AE-DQN训练两个网络:一个DQN网络,用Q表示;一个AE网络,用E表示。该算法每隔L次迭代就使用函数AENUpdate(E
-1
,λ,D)从E中创建一个线性上下文赌博机模型(contextual linear bandit model),AENUpdate(E
-1
,λ,D)函数使用激活后的E的LastLayer层作为特征,即
(s)←LastLayerActivations(E(s)),然后被用来创建一个线性上下文赌博机模型(V
a
=λI+
,b
a
=
,AENUpdate(E
-1
,λ,D)函数通过求解该模型进行更新,并将结果插入到目标AEN中(LastLayer
)。然后,这个线性上下文赌博机模型(E
-
,V)就会通过函数ACT()和Targets()来进行动作消除,ACT()在可接受的操作集
上执行ε-greedy策略。以ε的概率选择一个最高的Q值,以1-ε的概率随机选择一个Q值。TARGETS()通过只在允许的操作中选择最大值来估计价值函数,从而减少函数近似误差。
Lua代码片段分析
下面是使用Torch实现的AE-DQN的Lua代码:
1. for i = 1, table.getn(region_hight) do 2. local net = nn.Sequential() 3. net:add(nn.SpatialConvolution(4, n_filters, in_col, region_hight[i])) 4. net:add(nn.ReLU()) 5. --net:add(nn.SpatialDropout(0.5)) 6. net:add(nn.SpatialMaxPooling(1, in_row_s-region_hight[i]+1)) 7. net_concat:add(net) 8. end 9. net_s:add(net_concat) 10. net_s:add(nn.Reshape(tot_filters_s)) 11. net_s:add(nn.Linear(tot_filters_s, output_size)) 12. if args.shallow_elimination_flag ==0 then 13. net_s:add(nn.Sigmoid()) 14. end
这段是搭建AEN的代码,nn.SpatialConvolution的参数分别为:4、n_filters、in_col和region_hight[i],其中n_fliters表示每个region中filter的个数;in_col表示filter的宽度,默认取300;region_hight[i]表示filter的高度,默认从{1,2,3}中取值。
1. net_s:add(nn.Reshape(unpack(input_dims_s))) 2. local net_concat = nn.Concat(2) 3. for i = 1, table.getn(region_hight) do 4. local net = nn.Sequential() 5. net:add(nn.SpatialConvolution(4, n_filters, in_col, region_hight[i])) 6. net:add(nn.ReLU()) 7. net:add(nn.SpatialMaxPooling(1, in_row_s-region_hight[i]+1)) 8. net_concat:add(net) 9. end 10. net_s:add(net_concat) 11. net_s:add(nn.Reshape(tot_filters_s)) 12. net_s:add(nn.Linear(tot_filters_s, output_size))
这段是搭建DQN的代码,其中n_filters默认取500、in_col默认取300、region_hight[i]默认从{1,2,3}中取值。
在真实场景特别是文本类游戏中,很有可能会在海量的动作空间中进行选择,而AEDQN则能消除那些不相关的动作,限制智能体选择动作的空间,从而不仅提高了智能体的训练速度,并且显著提高了智能体的性能。