一.前言

Dueling network 是一篇来自2015年的论文。与之前介绍的DRQN不同,这篇论文提出了一个新的网络架构,这个架构不但提高了最终效果,而且还可以和其他的算法相结合以获取更加优异的表现。它和Prioritized Replay的结合成为了当时世界最先进的算法。

在看这篇文章之前假设已经对DQN有了解,并且可以看懂DQN的代码及其运行原理。如果还没有了解的话,可以去看一下https://aistudio.baidu.com/aistudio/projectdetail/2231135 这篇文章的代码也是直接在其基础上进行修改实现的。

下面是原论文链接,感兴趣的朋友可以看一下。

  • https://arxiv.org/pdf/1511.06581v3.pdf

这篇论文相对来说还是比较好理解的,也挺简单。下面主要根据论文讨论一下相关内容

二.网络结构

之前的DQN网络在将图片卷积获取特征之后会输入几个全连接层,经过训练直接输出在该state下各个action的价值也就是Q(s,a)。而Dueling network则不同,它在卷积网络之后引出了两个不同的分支,一个分支用于预测state的价值,另一个用于预测每个action的优势。最后将这两个分支的结果合并输出Q(s,a),具体怎么合并后边会讲。

可视化网络结构如下图所示,可以非常直观的看出两者的不同:

三.原理

1.为什么采用Dueling架构(Dueling架构的好处是什么)

(1)Dueling network与DQN最主要的不同就是将State与action进行了一定程度的分离,虽然最终的输出依然相同,但在计算的过程中,state不再完全依赖于action的价值来进行判断,可以进行单独的价值预测。这其实是十分有用的,模型既可以学习到某一个state的价值是多少,也可以学习到在该state下不同action的价值是多少,它可以对环境中的state和action进行相对独立而又紧密结合的观察学习,可以进行更灵活的处理。同时在某些state中,action的选择并不会对state产生影响,这时候Dueling模型就可以有更加强大的表现。这是非常好理解的,DQN只能根据State下action的价值Q(s,a)对state进行预测,当action对state的影响很小的时候(二者之间没有足够的联系),这时可以单独预测的Dueling自然有更好的发挥。

举个例子,下面是从论文中扒的一张图,红色的部分表示模型的注意力。下图第一行。在当前state下,路上没有其他车辆,这时候不管是采取向左还是向右的动作,state的价值都不会变。所以action分支在图上并没有红点,因为它这个时候不管采取什么动作都不会改变价值,也就没必要关注。而state的关注点在地平线,也就是道路的尽头,因为车子肯定是从地平线出现的,一旦画面中出现了其他车辆,这个state的价值自然就会发生改变。

而在下图第二行,车子周围有了很多车辆,这时候state的关注点除了地平线以外,还有临近的车辆。因为画面中有没有车辆和我们驾驶的车子旁边有没有车辆都可能导致碰撞的发生,从而影响state的价值。而action的关注点也到了周边车辆上,这时候动作的选择就会直接影响到价值的大小,因此会格外关注。

(2)论文还说明,在具有多个冗余或者近似的动作时,Dueling可以比DQN更快的识别出策略中的正确操作。

2.如何通过Dueling架构输出Q(s,a)

这个就是基本的强化学习知识,在此不过多赘述,懂DQN的对此肯定也有所了解。简单说一下优势A(s,a)函数。 V是状态的价值,Q函数是该状态下每个动作的价值。用Q减去V,得到的就是每一个动作相对于平均价值的好坏,也就是它们的重要程度。因此Q就可以通过V+A来得到。

3.原始公式Q=V+A的局限性

单纯的根据Q=V+A没办法得到准确而且固定(相对唯一)的V和A的值,因为V和A同时加上或者减去一个常数,最终结果不会改变。比如,V=1,A=2,Q=V+A=3,而V=2,A=1,Q仍然等于3。在最终输出Q值不变的情况下,V和A可以有很多的取值。因此原始的公式具有“不可识别性”。也就是说,最终的输出V和A会尽力去拟合结果Q,而不会过多的关注自身的值是多少。这个时候模型并不关心V和A单独的值,而只是去关心它们两个相加之后的值。这显然是不合适的。

4.两个改进的公式

因此,作者在论文中给出了两个不同的改进公式,需要提前说明的是,这两个公式的最终效果是类似的。都可以使用。

不同点在于,第一个公式是每一个A都减去A的最大值,第二个公式是每一个A都减去A的平均值。这样即使V和A都分别加减同样的的常数,最终的结果也不会相同。

更通俗直观的解释是,这样一来,在Q确定的情况下,V和A的值是相对固定的。并不会随着输入的不同而有所改变。

为了更好的理解这两个公式和原始公式的区别。下面简单贴上一段代码,可以分别运行一下三个公式,打印相关输出,自己具体感受一下不同的公式得到的结果有什么不同。

每一次运行生成的随机数都是不一样的,相当于输入的特征是不同的。但是Q的真实值是提前设定好的,在不同的训练中相当于不变。

可以看到。在使用原始公式时,虽然最终的结果相同,但是每次得到的V和A的值都会随着输入的不同而改变。但是应用改进后的公式,不管输入怎么改变,在Q不变的情况下,最终都会拟合成相近的数

import paddle
import numpy as np
x1=paddle.nn.Linear(10,1)
x2=paddle.nn.Linear(10,2)
a=paddle.to_tensor(np.random.random([1,10]),dtype='float32')
b=paddle.to_tensor(np.random.random([1,10]),dtype='float32')
#print(a.numpy()[0])
#print(b.numpy()[0])
cost=paddle.nn.MSELoss()
Q=paddle.to_tensor([3.5,4.2])
opt1=paddle.optimizer.Adam(learning_rate=1e-2,parameters=x1.parameters())
opt2=paddle.optimizer.Adam(learning_rate=1e-2,parameters=x2.parameters())
for i in range(150):
    V=x1(a)
    A=x2(b)
    #A=A-paddle.mean(A,axis=-1,keepdim=True)
    #A=A-paddle.max(A,axis=-1,keepdim=True)
    y=V+A
    loss=cost(y,Q)
    #print(loss.numpy()[0])
    #print(y.numpy()[0])
    #print(V.numpy()[0])
    #print(A.numpy()[0])
    loss.backward()
    opt1.step()
    opt1.clear_grad()
    opt2.step()
    opt2.clear_grad()
    

四.代码实现

需要提前说明两点。

  • 我并没有严格按照论文中提到的模型参数来搭建网络,比如每一个层的神经元数量之类的。但这并没有那么重要,在使用的时候根据具体的情况来调整超参数即可。其他方面还是和论文一样的。
  • 我只是简单实现了基础的Dueling network,并没有将Prioritized Replay一起实现。这个将会在写完Prioritized Replay的项目后实现(大概
#导入会用到的第三方库
import parl
from parl.utils import logger
import paddle
import copy
import numpy as np
import os
import gym
import random
import collections
#设置会用到的超参数
learn_freq = 5 # 训练频率,不需要每一个step都learn,攒一些新增经验后再learn,提高效率
memory_size = 20000    # replay memory的大小(数据集的大小),越大越占用内存
memory_warmup_size = 200  # replay_memory 里需要预存一些经验数据,再开启训练
batch_size = 32   # 每次给agent learn的数据数量,从replay memory随机里sample一批数据出来
lr = 0.0005 # 学习率
gamma = 0.99 # reward 的衰减因子,一般取 0.9 到 0.999 不等
max_episode = 2000 #定义训练次数
best_acc=0.0 #定义判断是否保存模型的初始得分阈值
mean=True   #是否选用平均值公式
#搭建网络
class Model(paddle.nn.Layer):
    def __init__(self, obs_dim,act_dim):
        super(Model,self).__init__()
        #2个分支,每个分支3层全连接层
        self.state_value =  paddle.nn.Sequential(
                                                paddle.nn.Linear(obs_dim,128),
                                                paddle.nn.ReLU(),
                                                paddle.nn.Linear(128,128),
                                                paddle.nn.ReLU(),                                        
                                                paddle.nn.Linear(128,1))  #输出state的价值,只需要一个神经元即可。

        self.action_value = paddle.nn.Sequential(
                                                paddle.nn.Linear(obs_dim,128),
                                                paddle.nn.ReLU(),
                                                paddle.nn.Linear(128,128),
                                                paddle.nn.ReLU(),                                        
                                                paddle.nn.Linear(128,act_dim))


    def forward(self, obs):
        #输入state,输出所有action对应的Q,[Q(s,a1), Q(s,a2), Q(s,a3)...]
        #分别获取输出
        state_value = self.state_value(obs)
        action_value = self.action_value(obs)
        #根据不同公式将两个输出合并
        if mean:
            action_value = action_value-paddle.mean(action_value,axis=-1,keepdim=True)   #按行求平均值,保持维度便于计算
        else:
            action_value = action_value-paddle.max(action_value,axis=-1,keepdim=True)
        Q=state_value+action_value
        return Q
#DQN算法
class DQN(parl.Algorithm):
    def __init__(self, model, act_dim=None, gamma=None, lr=None):
        self.model = model
        self.target_model = copy.deepcopy(model)    #复制predict网络得到target网络,实现fixed-Q-target 功能

        #数据类型是否正确
        assert isinstance(act_dim, int)
        assert isinstance(gamma, float)
        assert isinstance(lr, float)

        self.act_dim = act_dim
        self.gamma = gamma
        self.lr = lr
        self.optimizer=paddle.optimizer.Adam(learning_rate=self.lr,parameters=self.model.parameters())    # 使用Adam优化器
    
    #预测功能
    def predict(self, obs):
        return self.model.forward(obs)
        
    def learn(self, obs, action, reward, next_obs, terminal):

        # 从target_model中获取 max Q' 的值,用于计算target_Q
        next_predict_Q = self.target_model.forward(next_obs)
        best_v = paddle.max(next_predict_Q, axis=-1)#next_predict_Q的每一个维度(行)都求最大值,因为每一行就对应一个St,行数就是我们输入数据的批次大小
        best_v.stop_gradient = True                 #阻止梯度传递,因为要固定模型参数
        terminal = paddle.cast(terminal, dtype='float32')    #转换数据类型,转换为float32
        target = reward + (1.0 - terminal) * self.gamma * best_v  #Q的现实值

        predict_Q = self.model.forward(obs)  # 获取Q预测值

        #接下来一步是获取action所对应的Q(s,a)
        action_onehot = paddle.nn.functional.one_hot(action, self.act_dim)    # 将action转onehot向量,比如:3 => [0,0,0,1,0]
        action_onehot = paddle.cast(action_onehot, dtype='float32')        
        predict_action_Q = paddle.sum(
                                      paddle.multiply(action_onehot, predict_Q)              #逐元素相乘,拿到action对应的 Q(s,a)
                                      , axis=1)  #对每行进行求和运算,注意此处进行求和的真正目的其  # 比如:pred_value = [[2.3, 5.7, 1.2, 3.9, 1.4]], action_onehot = [[0,0,0,1,0]]
                                                #实是变换维度,类似于矩阵转置。与target形式相同。 #  ==> pred_action_value = [[3.9]]


        # 计算 Q(s,a) 与 target_Q的均方差,得到损失。让一组的输出逼近另一组的输出,是回归问题,故用均方差损失函数
        loss=paddle.nn.functional.square_error_cost(predict_action_Q, target)         
        cost = paddle.mean(loss)
        cost.backward()   #反向传播
        self.optimizer.step()  #更新参数
        self.optimizer.clear_grad()  #清除梯度

    def sync_target(self):
        self.target_model = copy.deepcopy(model)    #复制predict网络得到target网络,实现fixed-Q-target 功能
class Agent(parl.Agent):
    def __init__(self,
                 algorithm,
                 act_dim,
                 e_greed=0.1,  
                 e_greed_decrement=0 ):

        #判断输入数据的类型是否是int型
        assert isinstance(act_dim, int)

        self.act_dim = act_dim
        
        #调用Agent父类的对象,将算法类algorithm输入进去,目的是我们可以调用algorithm中的成员
        super(Agent, self).__init__(algorithm)
        ''' 实例化
        self.alg=DQN()
        '''

        self.global_step = 0          #总运行步骤
        self.update_target_steps = 200  # 每隔200个training steps再把model的参数复制到target_model中

        self.e_greed = e_greed  # 有一定概率随机选取动作,探索
        self.e_greed_decrement = e_greed_decrement  # 随着训练逐步收敛,探索的程度慢慢降低

    #参数obs都是单条输入,与learn函数的参数不同
    def sample(self, obs):
        sample = np.random.rand()  # 产生0~1之间的小数
        if sample < self.e_greed:
            act = np.random.randint(self.act_dim)  # 探索:每个动作都有概率被选择
        else:
            act = self.predict(obs)  # 选择最优动作
        self.e_greed = max(
            0.01, self.e_greed - self.e_greed_decrement)  # 随着训练逐步收敛,探索的程度慢慢降低
        return act        

    #通过神经网络获取输出
    def predict(self, obs):  # 选择最优动作
        obs=paddle.to_tensor(obs,dtype='float32')  #将目标数组转换为张量
        predict_Q=self.alg.predict(obs).numpy()    #将结果张量转换为数组
        act = np.argmax(predict_Q)  # 选择Q最大的下标,即对应的动作
        return act

    #这里的learn函数主要包括两个功能。1.同步模型参数2.更新模型。这两个功能都是通过调用algorithm算法里面的函数最终实现的。
    #注意,此处输入的参数均是一批数据组成的数组
    def learn(self, obs, act, reward, next_obs, terminal):
        # 每隔200个training steps同步一次model和target_model的参数
        if self.global_step % self.update_target_steps == 0:
            self.alg.sync_target()
        self.global_step += 1      #每执行一次learn函数,总次数+1

        #act = np.expand_dims(act, -1) #类似于做一个矩阵的转置。与其他数组形式对称,便于一条一条的获取数据

        #转换为张量
        obs=paddle.to_tensor(obs,dtype='float32')
        act=paddle.to_tensor(act,dtype='int32')
        reward=paddle.to_tensor(reward,dtype='float32')
        next_obs=paddle.to_tensor(next_obs,dtype='float32')
        terminal=paddle.to_tensor(terminal,dtype='float32')
        #进行学习
        self.alg.learn(obs, act, reward, next_obs, terminal)
class ReplayMemory(object):
    def __init__(self, max_size):
        #创建一个固定长度的队列作为缓冲区域,当队列满时,会自动删除最老的一条信息
        self.buffer = collections.deque(maxlen=max_size)

    # 增加一条经验到经验池中
    def append(self, exp):
        self.buffer.append(exp)

    # 从经验池中选取N条经验出来
    def sample(self, batch_size):
        mini_batch = random.sample(self.buffer, batch_size)  #返回值是个列表
        obs_batch, action_batch, reward_batch, next_obs_batch, done_batch = [], [], [], [], []
    
        for experience in mini_batch:
            s, a, r, s_p, done = experience
            obs_batch.append(s)
            action_batch.append(a)
            reward_batch.append(r)
            next_obs_batch.append(s_p)
            done_batch.append(done)
        #将列表转换为数组并转换数据类型
        return np.array(obs_batch).astype('float32'), \
            np.array(action_batch).astype('float32'), np.array(reward_batch).astype('float32'),\
            np.array(next_obs_batch).astype('float32'), np.array(done_batch).astype('float32')

    #输出队列的长度
    def __len__(self):
        return len(self.buffer)
# 训练一个episode
def run_episode(env, agent, rpm):   #rpm就是经验池
    total_reward = 0
    #重置环境
    obs = env.reset()
    step = 0
    while True:
        step += 1
        action = agent.sample(obs)  # 采样动作,所有动作都有概率被尝试到
        next_obs, reward, done, _ = env.step(action)
        rpm.append((obs, action, reward, next_obs, done))   #搜集数据

        #存储足够多的经验之后按照间隔进行训练
        if (len(rpm) > memory_warmup_size) and (step % learn_freq == 0):
            (batch_obs, batch_action, batch_reward, batch_next_obs,batch_done) = rpm.sample(batch_size)
            agent.learn(batch_obs, batch_action, batch_reward,batch_next_obs,batch_done)  # s,a,r,s',done

        total_reward += reward
        obs = next_obs
        if done:
            break
    return total_reward

# 评估 agent, 跑 5 个episode,总reward求平均
def evaluate(env, agent, render=False):
    eval_reward = []   #列表存储所有episode的reward
    for i in range(5):
        obs = env.reset()
        episode_reward = 0
        while True:
            action = agent.predict(obs)  # 预测动作,只选最优动作
            obs, reward, done, _ = env.step(action)
            episode_reward += reward
            if render:
                env.render()
            if done:
                break
        eval_reward.append(episode_reward)
    return np.mean(eval_reward)  #求平均值
#仿真环境
env = gym.make('CartPole-v1')  
action_dim = env.action_space.n 
obs_shape = env.observation_space.shape  

#模型保存路径
save_path = './dqn_model.ckpt'

rpm = ReplayMemory(memory_size)  # 实例化DQN的经验回放池

# 根据parl框架构建agent
model = Model(obs_dim=obs_shape[0],act_dim=action_dim)
algorithm = DQN(model, act_dim=action_dim, gamma=gamma, lr=lr)
agent = Agent(
    algorithm,
    act_dim=action_dim,
    e_greed=0.1,  # 有一定概率随机选取动作,探索
    e_greed_decrement=1e-6)  # 随着训练逐步收敛,探索的程度慢慢降低


# 先往经验池里存一些数据,避免最开始训练的时候样本丰富度不够
while len(rpm) < memory_warmup_size:
    run_episode(env, agent, rpm)

# 开始训练
episode = 0
while episode < max_episode:  # 训练max_episode个回合,test部分不计算入episode数量 
    # train part
    #for循环的目的是每50次进行一下测试
    for i in range(0, 50):
        total_reward = run_episode(env, agent, rpm)
        episode += 1
    # test part
    eval_reward = evaluate(env, agent, render=False)  # render=True 查看显示效果

    #保存最优模型
    if eval_reward>best_acc:
        best_acc=eval_reward
        agent.save(save_path)

    #将信息写入日志文件
    logger.info('episode:{}    e_greed:{}   test_reward:{}'.format(
        episode, agent.e_greed, eval_reward))
uate(env, agent, render=False)  # render=True 查看显示效果

    #保存最优模型
    if eval_reward>best_acc:
        best_acc=eval_reward
        agent.save(save_path)

    #将信息写入日志文件
    logger.info('episode:{}    e_greed:{}   test_reward:{}'.format(
        episode, agent.e_greed, eval_reward))
    
[32m[11-18 20:18:46 MainThread @machine_info.py:91][0m Cannot find available GPU devices, using CPU or other devices now.
[32m[11-18 20:18:48 MainThread @3293565891.py:43][0m episode:50    e_greed:0.09850399999999851   test_reward:25.0
[32m[11-18 20:18:50 MainThread @3293565891.py:43][0m episode:100    e_greed:0.09756899999999757   test_reward:18.4
[32m[11-18 20:18:52 MainThread @3293565891.py:43][0m episode:150    e_greed:0.09654399999999655   test_reward:17.6
[32m[11-18 20:18:56 MainThread @3293565891.py:43][0m episode:200    e_greed:0.09438799999999439   test_reward:165.8
[32m[11-18 20:19:15 MainThread @3293565891.py:43][0m episode:250    e_greed:0.08512999999998513   test_reward:500.0
[32m[11-18 20:19:39 MainThread @3293565891.py:43][0m episode:300    e_greed:0.07190899999997191   test_reward:146.6
[32m[11-18 20:20:01 MainThread @3293565891.py:43][0m episode:350    e_greed:0.060562999999960565   test_reward:135.6
[32m[11-18 20:20:16 MainThread @3293565891.py:43][0m episode:400    e_greed:0.05236899999995237   test_reward:128.6
[32m[11-18 20:20:29 MainThread @3293565891.py:43][0m episode:450    e_greed:0.04607799999994608   test_reward:131.6
[32m[11-18 20:20:42 MainThread @3293565891.py:43][0m episode:500    e_greed:0.039114999999939115   test_reward:125.0
[32m[11-18 20:20:54 MainThread @3293565891.py:43][0m episode:550    e_greed:0.032993999999932994   test_reward:116.2
[32m[11-18 20:21:17 MainThread @3293565891.py:43][0m episode:650    e_greed:0.021313999999921313   test_reward:115.2
[32m[11-18 20:21:28 MainThread @3293565891.py:43][0m episode:700    e_greed:0.016005999999916004   test_reward:195.8
[32m[11-18 20:22:15 MainThread @3293565891.py:43][0m episode:850    e_greed:0.01   test_reward:262.2
[32m[11-18 20:22:51 MainThread @3293565891.py:43][0m episode:900    e_greed:0.01   test_reward:323.8
[32m[11-18 20:23:26 MainThread @3293565891.py:43][0m episode:950    e_greed:0.01   test_reward:417.6
[32m[11-18 20:24:05 MainThread @3293565891.py:43][0m episode:1000    e_greed:0.01   test_reward:500.0
[32m[11-18 20:25:33 MainThread @3293565891.py:43][0m episode:1100    e_greed:0.01   test_reward:435.0
[32m[11-18 20:26:08 MainThread @3293565891.py:43][0m episode:1150    e_greed:0.01   test_reward:500.0
[32m[11-18 20:26:26 MainThread @3293565891.py:43][0m episode:1200    e_greed:0.01   test_reward:118.0
[32m[11-18 20:26:41 MainThread @3293565891.py:43][0m episode:1250    e_greed:0.01   test_reward:143.4
[32m[11-18 20:27:07 MainThread @3293565891.py:43][0m episode:1300    e_greed:0.01   test_reward:237.4
[32m[11-18 20:27:30 MainThread @3293565891.py:43][0m episode:1350    e_greed:0.01   test_reward:134.0
[32m[11-18 20:27:52 MainThread @3293565891.py:43][0m episode:1400    e_greed:0.01   test_reward:500.0
[32m[11-18 20:28:30 MainThread @3293565891.py:43][0m episode:1450    e_greed:0.01   test_reward:118.6
[32m[11-18 20:29:00 MainThread @3293565891.py:43][0m episode:1500    e_greed:0.01   test_reward:431.8
[32m[11-18 20:29:49 MainThread @3293565891.py:43][0m episode:1550    e_greed:0.01   test_reward:500.0
[32m[11-18 20:30:28 MainThread @3293565891.py:43][0m episode:1600    e_greed:0.01   test_reward:500.0
[32m[11-18 20:31:17 MainThread @3293565891.py:43][0m episode:1650    e_greed:0.01   test_reward:500.0
[32m[11-18 20:31:59 MainThread @3293565891.py:43][0m episode:1700    e_greed:0.01   test_reward:500.0
[32m[11-18 20:32:43 MainThread @3293565891.py:43][0m episode:1750    e_greed:0.01   test_reward:500.0
[32m[11-18 20:33:14 MainThread @3293565891.py:43][0m episode:1800    e_greed:0.01   test_reward:500.0
[32m[11-18 20:33:47 MainThread @3293565891.py:43][0m episode:1850    e_greed:0.01   test_reward:156.2
[32m[11-18 20:34:11 MainThread @3293565891.py:43][0m episode:1900    e_greed:0.01   test_reward:134.6
[32m[11-18 20:34:57 MainThread @3293565891.py:43][0m episode:1950    e_greed:0.01   test_reward:500.0
[32m[11-18 20:35:46 MainThread @3293565891.py:43][0m episode:2000    e_greed:0.01   test_reward:441.2

效果非常不错。可以看出来,Dueling network的效果确实吊打DQN,不亏是达到STOA的模型。同时,目前的参数仍然不是最优参数,还有可以改进的空间,可以尝试调整一下给出的超参数。模型可以在更少的时间里得到更好的效果

空间中已经有训练好的模型,不想训练可以直接加载使用。

个人简介

作者:王祯皓

东北大学秦皇岛分校本科生

感兴趣方向:CV、RL

我在AI Studio上获得白银等级,点亮3个徽章,来互关呀~ https://aistudio.baidu.com/aistudio/personalcenter/thirdview/643440

Logo

学大模型,用大模型上飞桨星河社区!每天8点V100G算力免费领!免费领取ERNIE 4.0 100w Token >>>

更多推荐