Dueling DQN是一种基于DQN的改进算法,它的主要突破点在于利用模型结构将值函数表示成更细致的形式,使得模型能够拥有更好的表现。
首先我们可以给出如下公式并定义一个新变量:
q ( s t , a t ) = v ( s t ) + A ( s t + a t ) q(s_t,a_t)=v(s_t)+A(s_t+a_t) q(st,at)=v(st)+A(st+at)
也就是说,基于状态和行动的值函数 q q q可以分解成基于状态的值函数 v v v和优势函数 A A A。由于存在
E a t [ q ( s t , a t ) ] = v ( s t ) E_{a_t}[q(s_t,a_t)]=v(s_t) Eat[q(st,at)]=v(st)
所以如果所有状态行动的值函数不相同,一些状态行动价值 q ( s , a ) q(s,a) q(s,a)必然高于状态的价值 v ( s ) v(s) v(s),当然也会有一些状态行动对低于价值,于是优势函数可以表示出当前行动和平均表现之间的区别:如果由于平均表现,那么优势函数为正,反之则为负。
既然概念上又这样天然的分解,那么在设计模型时就可以考虑采用这样的结构:在保持网络主体结构不变的基础上,将原本网络中的单一输出变成两路输出,一个输出用于输出 v v v,它是一个一维标量;另一个输出用于输出 A A A,它的维度和行动数量相同,最后将两部分的输出加起来就是原本的 q q q值。
改变输出结构后,只需要对模型做很少的改变即可实现功能:模型前面部分可以保持不变,模型后面的部分从一路输出变为两路输出,最后合并为一个结果。
仅仅做这样的分解并不能获得好的效果,因为当 q q q值一定时, v v v和 a a a有无穷种可行的组合(例如,对于同样的 Q Q Q值,如果将 V V V值加上任意大小的常数 C C C,再将所有 A A A值减去 C C C,则得到的值依然不变,这就导致了训练的不稳定性。),而实际上只有很小一部分的组合是合乎情理、接近真实数值的。为了解决 q q q值和 v v v值建模不唯一性的问题,就需要对优势函数 A A A做限定。显然 A A A函数的期望值为0:
E a [ A ( s t , a t ) ] = E a ( q ( s t , a t ) − v ( s t ) ) = v ( s t ) − v ( s t ) = 0 E_a[A(s_t,a_t)]=E_a(q(s_t,a_t)-v(s_t))=v(s_t)-v(s_t)=0 Ea[A(st,at)]=Ea(q(st,at)−v(st))=v(st)−v(st)=0
那么我们就可以对输出的 A A A值进行约束,例如将公式变成:
q ( s t , a t ) = v ( s t ) + ( A ( s t , a t ) − 1 ∣ A ∣ ∑ a ′ A ( s t , a t ′ ) ) q(s_t,a_t)=v(s_t)+(A(s_t,a_t)-\dfrac{1}{|A|}\sum\limits_{a'}A(s_t,a_t')) q(st,at)=v(st)+(A(st,at)−∣A∣1a′∑A(st,at′))
让每一个 A A A值减去当前状态下所有 A A A值的平均数,就可以保证前面提到的期望值为0的约束,从而增加了 v v v和 A A A的输出稳定性。
另外一种约束是减去当前状态下的 A A A值的最大值。
q ( s t , a t ) = v ( s t ) + ( A ( s t , a t ) − max a ′ A ( s t , a t ′ ) ) q(s_t, a_t)=v(s_t)+(A(s_t,a_t)-\max\limits_{a'}A(s_t,a_t')) q(st,at)=v(st)+(A(st,at)−a′maxA(st,at′))
进行这样的分解有很多好处:
Dueling DQN 与 DQN 相比的差异只是在网络结构上,大部分代码依然可以继续沿用。我们定义状态价值函数和优势函数的复合神经网络VAnet
。
class Qnet(nn.Module):
def __init__(self, state_dim, hidden_dim, action_dim):
super(Qnet, self).__init__()
self.layer = nn.Sequential(
nn.Linear(state_dim, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, action_dim)
)
def forward(self, s):
s = self.layer(s)
return s
class VAnet(nn.Module):
def __init__(self, state_dim, hidden_dim, action_dim):
super(VAnet, self).__init__()
self.fc1 = nn.Linear(state_dim, hidden_dim) # 共享网络部分
self.fc_A = nn.Linear(hidden_dim, action_dim)
self.fc_V = 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
DQN算法包括Double DQN和Dueling DQN
class DQN:
def __init__(self, args):
self.args = args
self.hidden_dim = args.hidden_size
self.batch_size = args.batch_size
self.lr = args.lr
self.gamma = args.gamma # 折扣因子
self.epsilon = args.epsilon # epsilon-贪婪策略
self.target_update = args.target_update # 目标网络更新频率
self.count = 0 # 计数器,记录更新次数
self.num_episodes = args.num_episodes
self.minimal_size = args.minimal_size
self.dqn_type = args.dqn_type
self.env = gym.make(args.env_name)
random.seed(args.seed)
np.random.seed(args.seed)
self.env.seed(args.seed)
torch.manual_seed(args.seed)
self.replay_buffer = ReplayBuffer(args.buffer_size)
self.state_dim = self.env.observation_space.shape[0]
self.action_dim = 11 # 将连续动作分成11个离散动作
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
#########################################################################################################
if self.dqn_type == "DuelingDQN": # Dueling DQN采取不一样的网络框架
self.q_net = VAnet(self.state_dim, self.hidden_dim, self.action_dim).to(self.device)
self.target_q_net = VAnet(self.state_dim, self.hidden_dim, self.action_dim).to(self.device)
else:
self.q_net = Qnet(self.state_dim, self.hidden_dim, self.action_dim).to(self.device)
self.target_q_net = Qnet(self.state_dim, self.hidden_dim, self.action_dim).to(self.device)
#########################################################################################################
self.optimizer = Adam(self.q_net.parameters(), lr=self.lr)
def select_action(self, state): # epsilon-贪婪策略采取动作
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):
states = torch.tensor(transition["states"], dtype=torch.float).to(self.device)
actions = torch.tensor(transition["actions"]).view(-1, 1).to(self.device)
rewards = torch.tensor(transition["rewards"], dtype=torch.float).view(-1, 1).to(self.device)
next_states = torch.tensor(transition["next_states"], dtype=torch.float).to(self.device)
dones = torch.tensor(transition["dones"], dtype=torch.float).view(-1, 1).to(self.device)
q_values = self.q_net(states).gather(1, actions) # Q value
# 下个状态的最大Q值
#########################################################################################################
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: # 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 error
loss = torch.mean(F.mse_loss(q_values, q_targets)) # 均方误差损失函数
self.optimizer.zero_grad() # PyTorch中默认梯度会累积,这里需要显式将梯度置为0
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
def train_DQN(self):
return_list = []
max_q_value_list = []
max_q_value = 0
for i in range(10):
with tqdm(total=int(self.num_episodes / 10), desc=f'Iteration {i}') as pbar:
for episode in range(self.num_episodes // 10):
episode_return = 0
state = self.env.reset()
while True:
action = self.select_action(state)
max_q_value = self.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, self.env, self.action_dim)
next_state, reward, done, _ = self.env.step([action_continuous])
self.replay_buffer.add(state, action, reward, next_state, done)
if self.replay_buffer.size() > self.minimal_size:
s, a, r, s_, d = self.replay_buffer.sample(self.batch_size)
transitions = {"states": s, "actions": a, "rewards": r, "next_states": s_, "dones": d}
self.update(transitions)
state = next_state
episode_return += reward
if done: break
return_list.append(episode_return)
if (episode + 1) % 10 == 0:
pbar.set_postfix(
{
"episode": f"{self.num_episodes / 10 * i + episode + 1}",
"return": f"{np.mean(return_list[-10:]):3f}"
}
)
pbar.update(1)
return return_list, max_q_value_list
代码运行结果
根据代码运行结果我们可以发现,相比于传统的 DQN,Dueling DQN 在多个动作选择下的学习更加稳定,得到的回报最大值也更大。由 Dueling DQN 的原理可知,随着动作空间的增大,Dueling DQN 相比于 DQN 的优势更为明显。
总的来说,Dueling DQN 能够很好地学习到不同动作的差异性,在动作空间较大的环境下非常有效。
\quad
\quad
\quad
参考:
\quad
\quad
\quad
持续更新~有错误的话敬请指正!