DQN 改进算法

\(\)

8.1 简介 #

DQN 算法敲开了深度强化学习的大门,但是作为先驱性的工作,其本身存在着一些问题以及一些可以改进的地方。于是,在 DQN 之后,学术界涌现出了非常多的改进算法。本章将介绍其中两个非常著名的算法:Double DQNDueling DQN,这两个算法的实现非常简单,只需要在 DQN 的基础上稍加修改,它们能在一定程度上改善 DQN 的效果。如果读者想要了解更多、更详细的 DQN 改进方法,可以阅读 Rainbow 模型的论文及其引用文献。

8.2 Double DQN #

普通的 DQN 算法通常会导致对 $Q$ 值的过高估计 (overestimation)。传统 DQN 优化的 TD target

$$ r + \gamma\max_{a^\prime}Q_{\mathcal{w}^-}(s^\prime, a^\prime) $$

其中 $\max_{a^\prime}Q_{\mathcal{w}^-}(s^\prime, a^\prime)$ 由目标网络(参数为 $\mathcal{w}^-$)计算得出,我们还可以将其写成如下形式:

$$ Q_{\mathcal{w}^-}\left(s^\prime, \arg\max_{a^\prime}Q_{\mathcal{w}^-}(s^\prime, a^\prime) \right) $$

换句话说,$\max$ 操作实际可以被拆解为两部分:首先选取状态 $s^\prime$ 下的最优动作 $a^*=\arg\max_{a^\prime}Q_{\mathcal{w}^-}(s^\prime, a^\prime)$,接着计算该动作对应的价值 $Q_{\mathcal{w}^-}(s^\prime, a^*)$。 当这两部分采用同一套 $Q$ 网络进行计算时,每次得到的都是神经网络当前估算的所有动作价值中的最大值。考虑到通过神经网络估算的 $Q$ 值本身在某些时候会产生正向或负向的误差,在 DQN 的更新方式下神经网络会将正向误差累积。

例如,我们考虑一个特殊情形:在状态 $s^\prime$ 下所有动作的 $Q$ 值均为 $0$,即 $Q(s^\prime, a_i) = 0,\forall i$,此时正确的更新目标应为 $r+0=r$,但是由于神经网络拟合的误差通常会出现某些动作的估算有正误差的情况,即存在某个动作 $a^\prime$ 有 $Q(s^\prime, a^\prime) > 0$,此时我们的更新目标出现了过高估计,$r+\gamma\max Q>r+0$。因此,当我们用 DQN 的更新公式进行更新时,$Q(s,a)$ 也就会被过高估计了。同理,我们拿这个 $Q(s,a)$ 来作为更新目标来更新上一步的 $Q$ 值时,同样会过高估计,这样的误差将会逐步累积。对于动作空间较大的任务,DQN 中的过高估计问题会非常严重,造成 DQN 无法有效工作的后果。

为了解决这一问题,Double DQN 算法提出利用两个独立训练的神经网络估算 $\max_{a^\prime}Q_*(s^\prime, a^\prime)$。具体做法是将原有的 $\max_{a^\prime}Q_{\mathcal{w}^-}(s^\prime, a^\prime)$ 更改为 $Q_{\mathcal{w}^-}\left(s^\prime, \arg\max_{a^\prime}Q_{\mathcal{w}}(s^\prime, a^\prime) \right)$,即利用一套神经网络 $Q_{\mathcal{w}}$ 的输出选取价值最大的动作,但在使用该动作的价值时,用另一套神经网络 $Q_{\mathcal{w}^-}$ 计算该动作的价值。这样,即使其中一套神经网络的某个动作存在比较严重的过高估计问题,由于另一套神经网络的存在,这个动作最终使用的 $Q$ 值不会存在很大的过高估计问题。

在传统的 DQN 算法中,本来就存在两套 $Q$ 函数的神经网络——目标网络和训练网络(参见 7.2.2 节),只不过 $\max_{a^\prime}Q_{\mathcal{w}^-}(s^\prime, a^\prime)$ 的计算只用到了其中的目标网络,那么我们恰好可以直接将训练网络作为 Double DQN 算法中的第一套神经网络来选取动作,将目标网络作为第二套神经网络计算 $Q$ 值,这便是 Double DQN 的主要思想。由于在 DQN 算法中将训练网络的参数记为 $\mathcal{w}$,将目标网络的参数记为 $\mathcal{w}^-$,这与本节中 Double DQN 的两套神经网络的参数是统一的,因此,我们可以直接写出如下 Double DQN 的优化目标:

$$ r + \gamma Q_{\mathcal{w}^-}\left(s^\prime, \arg\max_{a^\prime}Q_{\mathcal{w}}(s^\prime, a^\prime) \right) $$

8.3 Double DQN 代码实践 #

Double DQN 的代码实现可以直接在 DQN 的基础上进行,无须做过多修改。

本节采用的环境是倒立摆 (Inverted Pendulum),该环境下有一个处于随机位置的倒立摆,如图 8-1 所示。

图8-1 Pendulum环境示意图
import random
import gymnasium as gym
import numpy as np
import torch
import torch.nn.functional as F
import matplotlib.pyplot as plt
import rl_utils
from tqdm import tqdm


class Qnet(torch.nn.Module):
    ''' 只有一层隐藏层的Q网络 '''
    def __init__(self, state_dim, hidden_dim, action_dim):
        super(Qnet, self).__init__()
        self.fc1 = torch.nn.Linear(state_dim, hidden_dim)
        self.fc2 = torch.nn.Linear(hidden_dim, action_dim)

    def forward(self, x):
        x = F.relu(self.fc1(x))
        return self.fc2(x)

class DQN:
    ''' DQN算法,包括Double DQN '''
    def __init__(self,
                 state_dim,
                 hidden_dim,
                 action_dim,
                 learning_rate,
                 gamma,
                 epsilon,
                 target_update,
                 device,
                 dqn_type='VanillaDQN'):
        self.action_dim = action_dim
        self.q_net = Qnet(state_dim, hidden_dim, self.action_dim).to(device)
        self.target_q_net = Qnet(state_dim, hidden_dim,
                                 self.action_dim).to(device)
        self.optimizer = torch.optim.Adam(self.q_net.parameters(),
                                          lr=learning_rate)
        self.gamma = gamma
        self.epsilon = epsilon
        self.target_update = target_update
        self.count = 0
        self.dqn_type = dqn_type
        self.device = device

    def take_action(self, state):
        if np.random.random() < self.epsilon:
            action = np.random.randint(self.action_dim)
        else:
            state = torch.tensor([state], dtype=torch.float).to(self.device)
            action = self.q_net(state).argmax().item()
        return action

    def max_q_value(self, state):
        state = torch.tensor([state], dtype=torch.float).to(self.device)
        return self.q_net(state).max().item()

    def update(self, transition_dict):
        states = torch.tensor(transition_dict['states'],
                              dtype=torch.float).to(self.device)
        actions = torch.tensor(transition_dict['actions']).view(-1, 1).to(
            self.device)
        rewards = torch.tensor(transition_dict['rewards'],
                               dtype=torch.float).view(-1, 1).to(self.device)
        next_states = torch.tensor(transition_dict['next_states'],
                                   dtype=torch.float).to(self.device)
        dones = torch.tensor(transition_dict['dones'],
                             dtype=torch.float).view(-1, 1).to(self.device)

        q_values = self.q_net(states).gather(1, actions)  # Q值
        # 下个状态的最大Q值
        if self.dqn_type == 'DoubleDQN': # DQN与Double DQN的区别
            max_action = self.q_net(next_states).max(1)[1].view(-1, 1)
            max_next_q_values = self.target_q_net(next_states).gather(1, max_action)
        else: # DQN的情况
            max_next_q_values = self.target_q_net(next_states).max(1)[0].view(-1, 1)
        q_targets = rewards + self.gamma * max_next_q_values * (1 - dones)  # TD误差目标
        dqn_loss = torch.mean(F.mse_loss(q_values, q_targets))  # 均方误差损失函数
        self.optimizer.zero_grad()  # PyTorch中默认梯度会累积,这里需要显式将梯度置为0
        dqn_loss.backward()  # 反向传播更新参数
        self.optimizer.step()

        if self.count % self.target_update == 0:
            self.target_q_net.load_state_dict(
                self.q_net.state_dict())  # 更新目标网络
        self.count += 1

lr = 1e-2
num_episodes = 200
hidden_dim = 128
gamma = 0.98
epsilon = 0.01
target_update = 50
buffer_size = 5000
minimal_size = 1000
batch_size = 64
device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")

env_name = 'Pendulum-v1'
env = gym.make(env_name)
state_dim = env.observation_space.shape[0]
action_dim = 11  # 将连续动作分成11个离散动作


def dis_to_con(discrete_action, env, action_dim):  # 离散动作转回连续的函数
    action_lowbound = env.action_space.low[0]  # 连续动作的最小值
    action_upbound = env.action_space.high[0]  # 连续动作的最大值
    return action_lowbound + (discrete_action / (action_dim - 1)) * (action_upbound - action_lowbound)

def train_DQN(agent, env, num_episodes, replay_buffer, minimal_size,
              batch_size):
    return_list = []
    max_q_value_list = []
    max_q_value = 0
    for i in range(10):
        with tqdm(total=int(num_episodes / 10),
                  desc='Iteration %d' % i) as pbar:
            for i_episode in range(int(num_episodes / 10)):
                episode_return = 0
                state, _ = env.reset()
                done = False
                while not done:
                    action = agent.take_action(state)
                    max_q_value = agent.max_q_value(
                        state) * 0.005 + max_q_value * 0.995  # 平滑处理
                    max_q_value_list.append(max_q_value)  # 保存每个状态的最大Q值
                    action_continuous = dis_to_con(action, env,
                                                   agent.action_dim)
                    next_state, reward, terminated, truncated, _ = env.step([action_continuous])
                    done = terminated or truncated
                    replay_buffer.add(state, action, reward, next_state, done)
                    state = next_state
                    episode_return += reward
                    if replay_buffer.size() > minimal_size:
                        b_s, b_a, b_r, b_ns, b_d = replay_buffer.sample(
                            batch_size)
                        transition_dict = {
                            'states': b_s,
                            'actions': b_a,
                            'next_states': b_ns,
                            'rewards': b_r,
                            'dones': b_d
                        }
                        agent.update(transition_dict)
                return_list.append(episode_return)
                if (i_episode + 1) % 10 == 0:
                    pbar.set_postfix({
                        'episode':
                        '%d' % (num_episodes / 10 * i + i_episode + 1),
                        'return':
                        '%.3f' % np.mean(return_list[-10:])
                    })
                pbar.update(1)
    return return_list, max_q_value_list

首先训练 DQN 并打印出其学习过程中最大 $Q$ 值的情况。

dqn_type = "DQN"

random.seed(0)
np.random.seed(0)
torch.manual_seed(0)
replay_buffer = rl_utils.ReplayBuffer(buffer_size)
agent = DQN(state_dim, hidden_dim, action_dim, lr, gamma, epsilon,
            target_update, device, dqn_type=dqn_type)
return_list, max_q_value_list = train_DQN(agent, env, num_episodes,
                                          replay_buffer, minimal_size,
                                          batch_size)

episodes_list = list(range(len(return_list)))
mv_return = rl_utils.moving_average(return_list, 5)
plt.plot(episodes_list, mv_return)
plt.xlabel('Episodes')
plt.ylabel('Returns')
plt.title('{} on {}'.format(dqn_type, env_name))
plt.show()

frames_list = list(range(len(max_q_value_list)))
plt.plot(frames_list, max_q_value_list)
plt.axhline(0, c='orange', ls='--')
plt.axhline(10, c='red', ls='--')
plt.xlabel('Frames')
plt.ylabel('Q value')
plt.title('{} on {}'.format(dqn_type, env_name))
plt.show()
Iteration 0: 100%|██████████████████████████████████████████████████████████████| 20/20 [00:01<00:00, 16.21it/s, episode=20, return=-1145.912]
Iteration 1: 100%|███████████████████████████████████████████████████████████████| 20/20 [00:01<00:00, 12.31it/s, episode=40, return=-794.284]
Iteration 2: 100%|███████████████████████████████████████████████████████████████| 20/20 [00:01<00:00, 11.85it/s, episode=60, return=-277.432]
Iteration 3: 100%|███████████████████████████████████████████████████████████████| 20/20 [00:01<00:00, 12.50it/s, episode=80, return=-211.900]
Iteration 4: 100%|██████████████████████████████████████████████████████████████| 20/20 [00:01<00:00, 11.83it/s, episode=100, return=-351.310]
Iteration 5: 100%|██████████████████████████████████████████████████████████████| 20/20 [00:01<00:00, 12.25it/s, episode=120, return=-211.143]
Iteration 6: 100%|██████████████████████████████████████████████████████████████| 20/20 [00:01<00:00, 12.36it/s, episode=140, return=-184.236]
Iteration 7: 100%|██████████████████████████████████████████████████████████████| 20/20 [00:01<00:00, 12.11it/s, episode=160, return=-227.500]
Iteration 8: 100%|██████████████████████████████████████████████████████████████| 20/20 [00:01<00:00, 11.82it/s, episode=180, return=-134.553]
Iteration 9: 100%|██████████████████████████████████████████████████████████████| 20/20 [00:01<00:00, 12.45it/s, episode=200, return=-299.846]

8-2

8-3

根据代码运行结果我们可以发现,DQN 算法在倒立摆环境中能取得不错的回报,最后的期望回报在 -250 左右,但是不少 $Q$ 值超过了 0,有一些还超过了 10,该现象便是 DQN 算法中的 $Q$ 值过高估计。我们现在来看一下 Double DQN 是否能对此问题进行改善。

dqn_type = "DoubleDQN"
Iteration 0: 100%|███████████████████████████████████████████████████████████████| 20/20 [00:01<00:00, 14.68it/s, episode=20, return=-909.474]
Iteration 1: 100%|███████████████████████████████████████████████████████████████| 20/20 [00:01<00:00, 11.82it/s, episode=40, return=-565.638]
Iteration 2: 100%|███████████████████████████████████████████████████████████████| 20/20 [00:01<00:00, 12.10it/s, episode=60, return=-206.229]
Iteration 3: 100%|███████████████████████████████████████████████████████████████| 20/20 [00:01<00:00, 11.70it/s, episode=80, return=-321.216]
Iteration 4: 100%|██████████████████████████████████████████████████████████████| 20/20 [00:01<00:00, 11.69it/s, episode=100, return=-356.023]
Iteration 5: 100%|██████████████████████████████████████████████████████████████| 20/20 [00:01<00:00, 11.61it/s, episode=120, return=-200.769]
Iteration 6: 100%|██████████████████████████████████████████████████████████████| 20/20 [00:01<00:00, 10.70it/s, episode=140, return=-204.140]
Iteration 7: 100%|██████████████████████████████████████████████████████████████| 20/20 [00:01<00:00, 11.21it/s, episode=160, return=-315.940]
Iteration 8: 100%|██████████████████████████████████████████████████████████████| 20/20 [00:01<00:00, 11.87it/s, episode=180, return=-173.561]
Iteration 9: 100%|██████████████████████████████████████████████████████████████| 20/20 [00:01<00:00, 11.42it/s, episode=200, return=-311.374]

8-4

8-5

我们可以发现,与普通的 DQN 相比,Double DQN 比较少出现 $Q$ 值大于 0 的情况,说明 $Q$ 值过高估计的问题得到了很大缓解。

8.4 Dueling DQN #

Dueling DQNDQN 另一种的改进算法,它在传统 DQN 的基础上只进行了微小的改动,但却能大幅提升 DQN 的表现。在强化学习中,我们将状态动作价值函数 $Q$ 减去状态价值函数 $V$ 的结果定义为优势函数 $A$,即

$$ A(s,a) = Q(s,a)-V(s) $$

$\color{red}在同一个状态下,所有动作的优势值之和为 0,因为所有动作的动作价值的期望就是这个状态的状态价值。$ 据此,在 Dueling DQN 中,$Q$ 网络被建模为:

$$ Q_{\eta,\alpha,\beta}(s,a)=V_{\eta,\alpha}(s) + A_{\eta,\beta}(s,a) $$

其中,$V_{\eta,\alpha}(s)$ 为状态价值函数,而 $A_{\eta,\beta}(s,a)$ 则为该状态下采取不同动作的优势函数,表示采取不同动作的差异性;$\eta$ 是状态价值函数和优势函数共享的网络参数,一般用在神经网络中,用来提取特征的前几层;而 $\alpha$ 和 $\beta$ 分别为状态价值函数和优势函数的参数。在这样的模型下,我们不再让神经网络直接输出 $Q$ 值,而是训练神经网络的最后几层的两个分支,分别输出状态价值函数和优势函数,再求和得到 $Q$ 值。Dueling DQN 的网络结构如图 8-2 所示。

8-6

图8-2 Dueling DQN的网络结构图

将状态价值函数和优势函数分别建模的好处在于:某些情境下智能体只会关注状态的价值,而并不关心不同动作导致的差异,此时将二者分开建模能够使智能体更好地处理与动作关联较小的状态。在图 8-3 所示的驾驶车辆游戏中,智能体注意力集中的部位被显示为橙色(另见彩插图 4),当智能体前面没有车时,车辆自身动作并没有太大差异,此时智能体更关注状态价值,而当智能体前面有车时 (智能体需要超车),智能体开始关注不同动作优势值的差异。

8-7

图8-3 状态价值和优势值的简单例子

对于 Dueling DQN 中的公式 $Q_{\eta,\alpha,\beta}(s,a)=V_{\eta,\alpha}(s) + A_{\eta,\beta}(s,a)$,它存在对于 $V$ 值和 $A$ 值建模不唯一性的问题。例如,对于同样的 $Q$ 值,如果将 $V$ 值加上任意大小的常数 $C$,再将所有 $A$ 值减去 $C$,则得到的 $Q$ 值依然不变,这就导致了训练的不稳定性。为了解决这一问题,Dueling DQN 强制最优动作的优势函数的实际输出为 0,即:

$$ Q_{\eta,\alpha,\beta}(s,a)=V_{\eta,\alpha}(s) + A_{\eta,\beta}(s,a) - \max_{a^\prime} A_{\eta,\beta}(s,a^\prime) $$

此时 $V(s)=\max_a Q(s,a)$,可以确保 $V$ 值建模的唯一性。在实现过程中,我们还可以用平均代替最大化操作,即:

$$ Q_{\eta,\alpha,\beta}(s,a)=V_{\eta,\alpha}(s) + A_{\eta,\beta}(s,a) - \cfrac{1}{|\mathcal{A}|}\sum_{a^\prime\in\mathcal{A}}A_{\eta,\beta}(s,a^\prime) $$

此时 $V(s)=\cfrac{1}{|\mathcal{A}|}\sum_{a^\prime\in\mathcal{A}}A_{\eta,\beta}(s,a^\prime)$。在下面的代码实现中,我们将采取此种方式,虽然它不再满足贝尔曼最优方程,但实际应用时更加稳定。

为什么 Dueling DQN 会比 DQN 好?部分原因在于 Dueling DQN 能更高效学习状态价值函数。每一次更新时,函数 $V$ 都会被更新,这也会影响到其他动作的 $Q$ 值。而传统的 DQN 只会更新某个动作的 $Q$ 值,其他动作的 $Q$ 值就不会更新。因此,Dueling DQN 能够更加频繁、准确地学习状态价值函数。

8.5 Dueling DQN 代码实践 #

Dueling DQNDQN 相比的差异只是在网络结构上,大部分代码依然可以继续沿用。我们定义状态价值函数和优势函数的复合神经网络 VAnet

class VAnet(torch.nn.Module):
    ''' 只有一层隐藏层的A网络和V网络 '''
    def __init__(self, state_dim, hidden_dim, action_dim):
        super(VAnet, self).__init__()
        self.fc1 = torch.nn.Linear(state_dim, hidden_dim)  # 共享网络部分
        self.fc_A = torch.nn.Linear(hidden_dim, action_dim)
        self.fc_V = torch.nn.Linear(hidden_dim, 1)

    def forward(self, x):
        A = self.fc_A(F.relu(self.fc1(x)))
        V = self.fc_V(F.relu(self.fc1(x)))
        Q = V + A - A.mean(1).view(-1, 1)  # Q值由V值和A值计算得到
        return Q


class DQN:
    ''' DQN算法,包括Double DQN和Dueling DQN '''
    def __init__(self,
                 state_dim,
                 hidden_dim,
                 action_dim,
                 learning_rate,
                 gamma,
                 epsilon,
                 target_update,
                 device,
                 dqn_type='VanillaDQN'):
        self.action_dim = action_dim
        if dqn_type == 'DuelingDQN':  # Dueling DQN采取不一样的网络框架
            self.q_net = VAnet(state_dim, hidden_dim,
                               self.action_dim).to(device)
            self.target_q_net = VAnet(state_dim, hidden_dim,
                                      self.action_dim).to(device)
        else:
            self.q_net = Qnet(state_dim, hidden_dim,
                              self.action_dim).to(device)
            self.target_q_net = Qnet(state_dim, hidden_dim,
                                     self.action_dim).to(device)
        self.optimizer = torch.optim.Adam(self.q_net.parameters(),
                                          lr=learning_rate)
        self.gamma = gamma
        self.epsilon = epsilon
        self.target_update = target_update
        self.count = 0
        self.dqn_type = dqn_type
        self.device = device

    def take_action(self, state):
        if np.random.random() < self.epsilon:
            action = np.random.randint(self.action_dim)
        else:
            state = torch.tensor([state], dtype=torch.float).to(self.device)
            action = self.q_net(state).argmax().item()
        return action

    def max_q_value(self, state):
        state = torch.tensor([state], dtype=torch.float).to(self.device)
        return self.q_net(state).max().item()

    def update(self, transition_dict):
        states = torch.tensor(transition_dict['states'],
                              dtype=torch.float).to(self.device)
        actions = torch.tensor(transition_dict['actions']).view(-1, 1).to(
            self.device)
        rewards = torch.tensor(transition_dict['rewards'],
                               dtype=torch.float).view(-1, 1).to(self.device)
        next_states = torch.tensor(transition_dict['next_states'],
                                   dtype=torch.float).to(self.device)
        dones = torch.tensor(transition_dict['dones'],
                             dtype=torch.float).view(-1, 1).to(self.device)

        q_values = self.q_net(states).gather(1, actions)
        if self.dqn_type == 'DoubleDQN':
            max_action = self.q_net(next_states).max(1)[1].view(-1, 1)
            max_next_q_values = self.target_q_net(next_states).gather(
                1, max_action)
        else:
            max_next_q_values = self.target_q_net(next_states).max(1)[0].view(
                -1, 1)
        q_targets = rewards + self.gamma * max_next_q_values * (1 - dones)
        dqn_loss = torch.mean(F.mse_loss(q_values, q_targets))
        self.optimizer.zero_grad()
        dqn_loss.backward()
        self.optimizer.step()

        if self.count % self.target_update == 0:
            self.target_q_net.load_state_dict(self.q_net.state_dict())
        self.count += 1

运行

dqn_type = "DuelingDQN"
Iteration 0: 100%|█████████████████████████████████████████████████████████| 20/20 [00:01<00:00, 10.51it/s, episode=20, return=-1062.013]
Iteration 1: 100%|██████████████████████████████████████████████████████████| 20/20 [00:02<00:00,  8.27it/s, episode=40, return=-194.236]
Iteration 2: 100%|██████████████████████████████████████████████████████████| 20/20 [00:02<00:00,  8.22it/s, episode=60, return=-156.037]
Iteration 3: 100%|██████████████████████████████████████████████████████████| 20/20 [00:02<00:00,  8.22it/s, episode=80, return=-195.879]
Iteration 4: 100%|█████████████████████████████████████████████████████████| 20/20 [00:02<00:00,  8.32it/s, episode=100, return=-294.113]
Iteration 5: 100%|█████████████████████████████████████████████████████████| 20/20 [00:02<00:00,  8.32it/s, episode=120, return=-184.157]
Iteration 6: 100%|█████████████████████████████████████████████████████████| 20/20 [00:02<00:00,  8.21it/s, episode=140, return=-211.566]
Iteration 7: 100%|█████████████████████████████████████████████████████████| 20/20 [00:02<00:00,  8.09it/s, episode=160, return=-278.817]
Iteration 8: 100%|█████████████████████████████████████████████████████████| 20/20 [00:02<00:00,  7.41it/s, episode=180, return=-212.112]
Iteration 9: 100%|█████████████████████████████████████████████████████████| 20/20 [00:02<00:00,  7.96it/s, episode=200, return=-225.713]

8-8

8-9

根据代码运行结果我们可以发现,相比于传统的 DQNDueling DQN 在多个动作选择下的学习更加稳定,得到的回报最大值也更大。由 Dueling DQN 的原理可知,随着动作空间的增大,Dueling DQN 相比于 DQN 的优势更为明显。之前我们在环境中设置的离散动作数为 11,我们可以增加离散动作数(例如 15、25 等),继续进行对比实验。

8.6 总结 #

在传统的 DQN 基础上,有两种非常容易实现的变式——Double DQNDueling DQNDouble DQN 解决了 DQN 中对 $Q$ 值的过高估计,而 Dueling DQN 能够很好地学习到不同动作的差异性,在动作空间较大的环境下非常有效。从 Double DQNDueling DQN 的方法原理中,我们也能感受到深度强化学习的研究是在关注深度学习和强化学习有效结合:一是在深度学习的模块的基础上,强化学习方法如何更加有效地工作,并避免深度模型学习行为带来的一些问题,例如使用 Double DQN 解决 $Q$ 值过高估计的问题;二是在强化学习的场景下,深度学习模型如何有效学习到有用的模式,例如设计 Dueling DQN 网络架构来高效地学习状态价值函数以及动作优势函数。

8.7 扩展阅读: 对 $Q$ 值过高估计的定量分析 #

我们可以对 $Q$ 值的过高估计做简化的定量分析。假设在 $s$ 状态下所有动作的期望回报均无差异,即 $Q^*(s,a)=V^*(s,a)$(此设置是为了定量分析所简化的情形,实际上不同动作的期望回报通常会存在差异);假设神经网络估算误差 $Q_{\mathcal{w}^-}(s,a)-V^*$ 服从 $[-1, 1]$ 之间的均匀独立同分布;假设动作空间大小为 $m$。那么,对于任意状态 $s$,有:

$$ \mathbb{E}\left[ \max_a Q_{\mathcal{w}^-}(s,a) - \max_{a^\prime} Q_*(s,a^\prime) \right] = \cfrac{m-1}{m+1} $$

即动作空间 $m$ 越大时,$Q$ 值过高,估计越严重。

证明:将估算误差记为 $\epsilon_a = Q_{\mathcal{w}^-}(s,a)-\max_{a^\prime} Q_*(s,a^\prime)$,由于估算误差对于不同的动作是独立的,因此有:

$$ P\left( \max_a \epsilon_a \le x \right) = \prod_{a=1}^{m}P(\epsilon_a\le x) $$

$P(\epsilon_a\le x)$ 是 $\epsilon_a$ 的累积分布函数 (cumulative distribution function, CDF),它可以具体被写为:

$$ P(\epsilon_a\le x) = \begin{cases} 0, & \text{if }x \le -1 \\ \cfrac{1+x}{2}, & \text{if }x \in (-1,1) \\ 1, & \text{if }x \ge 1 \\ \end{cases} $$

因此,我们得到关于 $\max_a \epsilon_a$ 的累积分布函数:

$$ \begin{align*} P\left( \max_a \epsilon_a \le x \right) &= \prod_{a=1}^{m}P(\epsilon_a\le x) \\ &=\begin{cases} 0, & \text{if }x \le -1 \\ \left(\cfrac{1+x}{2}\right)^m, & \text{if }x \in (-1,1) \\ 1, & \text{if }x \ge 1 \\ \end{cases}\\ \end{align*} $$

最后我们可以得到:

$$ \begin{align*} \mathbb{E}\left[ \max_a\epsilon_a \right] &= \int_{-1}^{1}x\cfrac{d}{dx} P\left( \max_a \epsilon_a \le x \right) dx \\ &= \left[ \left(\cfrac{1+x}{2}\right)^m \cfrac{mx-1}{m+1} \right]_{-1}^{1} \\ &=\cfrac{m-1}{m+1} \end{align*} $$

虽然这一分析简化了实际环境,但它仍然正确刻画了 $Q$ 值过高估计的一些性质,比如 $Q$ 值的过高估计随动作空间大小 $m$ 的增加而增加,换言之,在动作选择数更多的环境中,$Q$ 值的过高估计会更严重。