1. 从DQN到进阶:我们为什么要改进它?
如果你跟着我一起玩过DQN(Deep Q-Network),肯定体验过那种“初窥门径”的兴奋感。看着一个智能体从零开始,在游戏环境里跌跌撞撞,最终学会通关,成就感爆棚。但玩得深入了,尤其是想在一些更复杂的环境里得到高分时,你可能会发现,原始的DQN有点“力不从心”。它有时候会表现得过于乐观,或者在某些状态下“选择困难”,导致学习效率不高,甚至难以收敛。
这其实不是你的代码写错了,而是DQN算法本身存在一些固有的“小毛病”。我刚开始做项目时,就遇到过智能体在某个简单游戏里分数死活上不去的情况,调参调到怀疑人生。后来深入研究才发现,问题出在算法原理层面。今天,我就想跟你聊聊DQN家族里两位非常重要的“升级版成员”:Double DQN(DDQN) 和 Dueling DQN。它们没有增加太多代码复杂度,却实实在在地解决了DQN的核心痛点。
简单来说,DDQN主要治的是DQN的“过度自信”问题,而Dueling DQN则优化了网络“思考问题”的方式。理解它们,不仅能让你把智能体训练得更快更好,更能让你深入理解价值函数估计的奥秘。接下来,我会用最直白的话把它们的原理讲透,并且手把手带你用TensorFlow 2.0实现一遍。放心,就算你刚接触强化学习不久,跟着我的思路和代码,也绝对能搞定。
2. 告别过度自信:Double DQN(DDQN)详解
2.1 DQN的“老毛病”:过估计问题
要理解DDQN,我们得先看看DQN到底哪里出了问题。你还记得DQN是怎么更新Q值的吗?它的目标Q值(Target Q)是这么算的:目标Q = 即时奖励 + 折扣因子 * 下一个状态的最大Q值。这个最大Q值,是从目标网络(Target Network)里,对下一个状态所有动作的Q值取max得到的。
问题就出在这个max操作上。想象一下,你是个老师,要评估一群学生的水平。DQN的做法是,让一个评分标准(目标网络)去给每个学生打分,然后直接取最高分作为这群学生的“代表水平”。但万一这个评分标准手松,给某个学生打了异常高的分呢?这个被高估的分数就成了整个群体的评价,导致你误以为这群学生水平都很高。在Q学习里,这就叫过估计:我们估计出来的Q值,系统地比真实的Q值要大。
过估计的危害很大。智能体会因为某些动作被高估,而反复选择它们,忽略了其他可能更好的动作,导致策略陷入局部最优,学习过程变得不稳定。我在训练一个玩“打砖块”的智能体时就遇到过,它总是执着于用同一个角度击球,因为那个动作的Q值被网络高估了,实际上换个角度得分效率更高。
2.2 DDQN的“分权制衡”妙招
Double DQN的解决方案非常巧妙,它用一种“分权制衡”的思想解决了这个问题。Nature DQN不是已经有两个网络了吗?一个是我们正在训练的主网络(Q-Estimation Network),另一个是用于计算目标值的目标网络(Q-Target Network)。DDQN说:别让一个网络既当“裁判”(选择动作)又当“评委”(评估价值)。
具体怎么做呢?我们把计算目标Q值的过程拆成两步:
- 选择动作:用我们正在训练的主网络,去看看在下一个状态
s'下,哪个动作a看起来最好(Q值最大)。这一步只负责“提名”。 - 评估价值:用目标网络,去评估在状态
s'下,执行刚才被“提名”的那个动作a,到底能值多少分(Q值是多少)。这一步负责“打分”。
用公式写出来,DQN的目标Q值是:
目标Q = r + γ * max(Q_target(s', a'))
而DDQN的目标Q值是:
目标Q = r + γ * Q_target(s', argmax(Q_estimation(s', a')))
看出区别了吗?argmax用的是主网络,Q_target用的是目标网络。这样一来,即使主网络因为训练初期不稳定,错误地高估了某个动作,但这个被高估的动作交给目标网络去评估时,目标网络可能会给出一个相对冷静的分数,从而避免了高估的传递。这就好比让两个老师独立工作,一个负责挑出最好的学生(可能挑错),另一个负责给这个学生打分(相对客观),最终的评价就会公允很多。
2.3 TensorFlow 2.0 实现DDQN
理论说清楚了,实现起来其实只比DQN多一两行代码。核心就在于修改计算目标Q值的那部分。我们假设你已经有了一个标准的DQN框架,包括经验回放缓冲区(Replay Buffer)、主模型(model)和目标模型(target_model)。
下面是最关键的训练步骤中的代码片段:
import tensorflow as tf
import numpy as np
def train_step(self, states, actions, rewards, next_states, dones, gamma=0.99):
"""
执行一次训练步骤。
参数:
states: 当前状态,形状 [batch_size, state_dim]
actions: 执行的动作,形状 [batch_size]
rewards: 获得的奖励,形状 [batch_size]
next_states: 下一个状态,形状 [batch_size, state_dim]
dones: 是否结束,形状 [batch_size]
gamma: 折扣因子
"""
batch_size = states.shape[0]
# 使用主网络预测下一个状态所有动作的Q值 [batch_size, action_dim]
next_q_values_main = self.model(next_states)
# 【DDQN核心步骤1】用主网络选择动作:找出每个样本中Q值最大的动作索引
next_actions = tf.argmax(next_q_values_main, axis=1) # 形状 [batch_size]
# 使用目标网络预测下一个状态的Q值 [batch_size, action_dim]
next_q_values_target = self.target_model(next_states)
# 【DDQN核心步骤2】用目标网络评估价值:根据主网络选出的动作索引,取出目标网络对应的Q值
# 这里使用 tf.gather 或 numpy 的高级索引来收集特定动作的Q值
next_q_value = tf.gather(next_q_values_target, next_actions, batch_dims=1)
# 或者用NumPy风格(如果都是Tensor,用tf的方法更优):
# indices = tf.stack([tf.range(batch_size), next_actions], axis=1)
# next_q_value = tf.gather_nd(next_q_values_target, indices)
# 计算目标Q值:如果回合结束(done=True),则只有即时奖励
target_q_values = rewards + (1 - dones) * gamma * next_q_value
target_q_values = tf.expand_dims(target_q_values, axis=-1) # 变成 [batch_size, 1]
# 准备训练用的目标:复制一份主网络对当前状态的预测,然后只更新被执行动作的那个Q值
with tf.GradientTape() as tape:
# 主网络对当前状态的预测
all_q_values = self.model(states) # [batch_size, action_dim]
# 收集被执行动作的预测Q值
action_masks = tf.one_hot(actions, self.action_dim) # [batch_size, action_dim]
predicted_q_values = tf.reduce_sum(all_q_values * action_masks, axis=1, keepdims=True) # [batch_size, 1]
# 计算损失(如Huber损失,对异常值更鲁棒)
loss = tf.keras.losses.Huber()(target_q_values, predicted_q_values)
# 计算梯度并更新主网络参数
gradients = tape.gradient(loss, self.model.trainable_variables)
self.optimizer.apply_gradients(zip(gradients, self.model.trainable_variables))
return loss.numpy()
这段代码里,next_actions = tf.argmax(next_q_values_main, axis=1) 就是“提名”动作,next_q_value = tf.gather(next_q_values_target, next_actions, batch_dims=1) 就是让目标网络对这个被提名的动作“打分”。其他地方,比如经验回放、目标网络的软更新(soft update),都和标准DQN完全一样。
我自己的实测经验是,在像CartPole(平衡杆)这类简单环境里,DDQN和DQN可能差别不大。但在动作空间更大、奖励更稀疏的环境里,比如Atari游戏中的某些关卡,DDQN能显著提高训练的稳定性和最终策略的质量。它相当于给算法加了一个“纠偏”机制,让学习过程更靠谱。
3. 重构价值评估:Dueling DQN 深度剖析
3.1 换个角度思考:状态价值与动作优势
如果说DDQN是修正评估方法,那Dueling DQN就是重构了评估体系。我们再来想想Q值的本质:Q(s, a)表示在状态s下执行动作a有多好。但这个“好”可以拆解吗?当然可以。
一个直觉是:一个动作的好坏,一部分取决于当前这个状态本身有多好,另一部分取决于这个特定动作相对于其他动作有多好。比如,在赛车游戏里,当前处于一个长直道(状态本身很好),那么无论你选择轻微左转、右转还是保持直线(不同动作),得分可能都不会太差。但在一个急弯处(状态本身有挑战性),选择正确的过弯路线(优势动作)和错误路线(劣势动作)的结果就会天差地别。
Dueling DQN正是基于这个直觉。它不再让神经网络直接输出一个Q(s, a),而是将其分解为两个部分:
- 状态价值函数 V(s):这个状态
s本身的平均价值,与具体采取哪个动作无关。它回答了“我现在处境如何?”。 - 优势函数 A(s, a):在状态
s下,动作a相对于平均动作的优势有多大。它回答了“在这个处境下,选这个动作比选别的动作好多少?”。
最终的Q值由两者相加得到:Q(s, a) = V(s) + A(s, a)。
为什么要这么做?最大的好处是学习效率的提升。在很多场景下,状态的价值比动作的差异更容易学习。比如,在游戏里“快要死了”这个状态,其价值V(s)肯定是负的,网络可以很快学到这一点。一旦学到了V(s)很低,那么所有动作的Q(s, a)都会有一个低的基线,网络可以更专注于去学习不同动作之间细微的相对优势A(s, a)。这尤其有利于处理那些很多动作价值相似的场景,网络不必再为每个相似动作重复学习相同的状态价值部分。
3.2 网络架构的革新与实现细节
Dueling DQN的网络结构和标准DQN有显著不同。标准DQN是输入状态,经过几层全连接层后,直接输出一个维度等于动作数量的向量,每个值就是Q(s, a)。
而Dueling DQN的网络在最后分叉了,形成两条“流”:
- 价值流(Value Stream):输出一个标量
V(s)。 - 优势流(Advantage Stream):输出一个向量
A(s, a),长度等于动作数量。
然后,我们需要将它们组合起来得到最终的Q(s, a)。这里有个重要的技术细节:Q(s, a) = V(s) + A(s, a) 这个公式存在“可辨识性”问题。因为给V(s)加上一个常数,同时给所有A(s, a)减去同一个常数,得到的Q(s, a)不变。这可能导致训练不稳定。为了解决这个问题,实际实现中,我们强制让优势函数的平均值为零。即采用如下组合方式:
Q(s, a) = V(s) + (A(s, a) - mean(A(s, a)))
这样,V(s)就被迫学习状态的真正平均价值,而A(s, a)则学习每个动作的相对优势。
3.3 用TensorFlow 2.0搭建Dueling网络
下面我们用TensorFlow 2.0的Keras API来构建一个Dueling DQN模型。这个模型可以像普通Keras模型一样被编译和训练。
import tensorflow as tf
from tensorflow.keras import layers, Model
class DuelingDQN(Model):
def __init__(self, state_dim, action_dim):
super(DuelingDQN, self).__init__()
self.action_dim = action_dim
# 共享的特征提取层
self.dense1 = layers.Dense(128, activation='relu')
self.dense2 = layers.Dense(64, activation='relu')
# 价值流 V(s)
self.value_dense = layers.Dense(32, activation='relu')
self.value_out = layers.Dense(1, activation=None) # 输出一个标量
# 优势流 A(s, a)
self.advantage_dense = layers.Dense(32, activation='relu')
self.advantage_out = layers.Dense(action_dim, activation=None) # 输出一个向量
def call(self, inputs):
# 共享特征
x = self.dense1(inputs)
x = self.dense2(x)
# 价值流
v = self.value_dense(x)
v = self.value_out(v) # 形状: [batch_size, 1]
# 优势流
a = self.advantage_dense(x)
a = self.advantage_out(a) # 形状: [batch_size, action_dim]
# 组合:Q(s,a) = V(s) + A(s,a) - mean(A(s,a))
# 为了计算均值,需要保持维度一致。先计算优势的均值,然后广播相减。
mean_a = tf.reduce_mean(a, axis=1, keepdims=True) # 形状: [batch_size, 1]
q_values = v + (a - mean_a) # 广播相加,形状: [batch_size, action_dim]
return q_values
# 创建模型示例
state_dim = 8 # 例如,状态维度为8
action_dim = 4 # 例如,有4个可选动作
model = DuelingDQN(state_dim=state_dim, action_dim=action_dim)
# 测试前向传播
test_state = tf.random.normal((1, state_dim))
q_vals = model(test_state)
print(f"模型输出Q值形状: {q_vals.shape}") # 应为 (1, 4)
在这个模型里,self.dense1和self.dense2是共享层,负责从原始状态中提取高级特征。之后网络一分为二,分别计算V(s)和A(s, a)。最后在call方法中,我们按照Q = V + (A - mean(A))的公式进行组合。注意tf.reduce_mean(a, axis=1, keepdims=True)这句,它确保了我们在每个样本的维度上对优势值求平均,并且保持维度以便广播运算。
训练Dueling DQN的其余部分——经验回放、目标网络、损失计算——和标准DQN一模一样。你只需要把原来的model换成我们这个DuelingDQN类的实例就可以了。我经常把Dueling结构和DDQN结合起来用,也就是在计算目标Q值时采用DDQN的方法,而网络本身采用Dueling结构,这样往往能获得“1+1>2”的效果,这个组合也被很多现代深度强化学习算法作为基础组件。
4. 强强联合:在实战中整合DDQN与Dueling DQN
4.1 为什么组合使用效果更佳?
在研究和工程实践中,DDQN和Dueling DQN并不是互斥的选择,它们解决的是不同层面的问题。DDQN从算法流程上入手,通过解耦动作选择和价值评估来减少过估计,让目标Q值的计算更准确。Dueling DQN则从网络架构上创新,通过分解价值函数让网络学习更高效、更稳健。
这就好比你要提高汽车性能,DDQN是优化了发动机的燃油喷射系统(提高效率,减少浪费),而Dueling DQN是重新设计了汽车的底盘和传动结构(优化力量分配,提升操控)。两者结合,才能造出一辆又快又稳的好车。在实际的Atari游戏基准测试中,将两者结合而成的Dueling Double DQN,其性能通常稳定地优于单独的DQN、DDQN或Dueling DQN。
4.2 完整的TensorFlow 2.0实战代码框架
下面,我给出一个整合了经验回放(Replay Buffer)、DDQN、Dueling网络架构的相对完整的智能体类框架。这个框架模块清晰,你可以直接用于CartPole、MountainCar等Gym环境进行测试。
import numpy as np
import tensorflow as tf
from collections import deque
import random
class ReplayBuffer:
"""经验回放缓冲区"""
def __init__(self, capacity):
self.buffer = deque(maxlen=capacity)
def push(self, state, action, reward, next_state, done):
self.buffer.append((state, action, reward, next_state, done))
def sample(self, batch_size):
batch = random.sample(self.buffer, batch_size)
states, actions, rewards, next_states, dones = zip(*batch)
return (np.array(states), np.array(actions), np.array(rewards, dtype=np.float32),
np.array(next_states), np.array(dones, dtype=np.float32))
def __len__(self):
return len(self.buffer)
class DuelingDoubleDQNAgent:
def __init__(self, state_dim, action_dim, lr=1e-3, gamma=0.99,
buffer_capacity=10000, batch_size=64, tau=0.005):
self.state_dim = state_dim
self.action_dim = action_dim
self.gamma = gamma
self.batch_size = batch_size
self.tau = tau # 目标网络软更新参数
# 创建主网络和目标网络(使用上面定义的DuelingDQN)
self.model = DuelingDQN(state_dim, action_dim)
self.target_model = DuelingDQN(state_dim, action_dim)
self.target_model.set_weights(self.model.get_weights()) # 初始权重相同
self.optimizer = tf.keras.optimizers.Adam(learning_rate=lr)
self.buffer = ReplayBuffer(buffer_capacity)
def select_action(self, state, epsilon):
"""ε-贪婪策略选择动作"""
if np.random.random() < epsilon:
return np.random.randint(self.action_dim)
else:
state_tensor = tf.convert_to_tensor([state], dtype=tf.float32)
q_values = self.model(state_tensor)
return tf.argmax(q_values[0]).numpy()
def update(self):
"""从缓冲区采样并更新一次网络"""
if len(self.buffer) < self.batch_size:
return
# 1. 采样
states, actions, rewards, next_states, dones = self.buffer.sample(self.batch_size)
states = tf.convert_to_tensor(states, dtype=tf.float32)
actions = tf.convert_to_tensor(actions, dtype=tf.int32)
rewards = tf.convert_to_tensor(rewards, dtype=tf.float32)
next_states = tf.convert_to_tensor(next_states, dtype=tf.float32)
dones = tf.convert_to_tensor(dones, dtype=tf.float32)
# 2. 计算目标Q值 (Double DQN 方式)
# 用主网络选动作
next_q_main = self.model(next_states) # [batch, action_dim]
next_actions = tf.argmax(next_q_main, axis=1) # [batch]
# 用目标网络评估
next_q_target = self.target_model(next_states) # [batch, action_dim]
# 收集对应动作的Q值
batch_indices = tf.range(self.batch_size, dtype=tf.int32)
indices = tf.stack([batch_indices, next_actions], axis=1)
next_q_selected = tf.gather_nd(next_q_target, indices) # [batch]
# 计算目标
target_q = rewards + (1 - dones) * self.gamma * next_q_selected
target_q = tf.expand_dims(target_q, 1) # [batch, 1]
# 3. 计算当前Q值并更新主网络
with tf.GradientTape() as tape:
# 计算当前预测的Q值
all_q = self.model(states) # [batch, action_dim]
# 只取出被采取动作的Q值
action_masks = tf.one_hot(actions, self.action_dim)
predicted_q = tf.reduce_sum(all_q * action_masks, axis=1, keepdims=True) # [batch, 1]
# 计算Huber损失
loss = tf.keras.losses.Huber()(target_q, predicted_q)
# 计算梯度并更新
gradients = tape.gradient(loss, self.model.trainable_variables)
self.optimizer.apply_gradients(zip(gradients, self.model.trainable_variables))
# 4. 软更新目标网络
self.soft_update_target_network()
def soft_update_target_network(self):
"""软更新目标网络参数:θ_target = τ * θ + (1 - τ) * θ_target"""
for target_var, var in zip(self.target_model.trainable_variables, self.model.trainable_variables):
target_var.assign(self.tau * var + (1 - self.tau) * target_var)
def save_model(self, path):
self.model.save_weights(path + '_model')
self.target_model.save_weights(path + '_target')
def load_model(self, path):
self.model.load_weights(path + '_model')
self.target_model.load_weights(path + '_target')
# 使用示例(伪代码流程)
# env = gym.make('CartPole-v1')
# agent = DuelingDoubleDQNAgent(state_dim=env.observation_space.shape[0],
# action_dim=env.action_space.n)
# epsilon = 1.0
# epsilon_decay = 0.995
# min_epsilon = 0.01
#
# for episode in range(num_episodes):
# state, _ = env.reset()
# total_reward = 0
# while True:
# action = agent.select_action(state, epsilon)
# next_state, reward, done, truncated, _ = env.step(action)
# agent.buffer.push(state, action, reward, next_state, done)
# agent.update()
# state = next_state
# total_reward += reward
# if done or truncated:
# break
# epsilon = max(min_epsilon, epsilon * epsilon_decay)
这个DuelingDoubleDQNAgent类集成了所有核心组件。ReplayBuffer负责存储和采样经验;DuelingDQN作为网络骨架;select_action实现ε-贪婪探索;update方法包含了DDQN的目标计算和网络训练;soft_update_target_network实现了目标网络的软更新,这是让训练过程平稳的关键技巧之一。
4.3 训练技巧与排坑指南
根据我多年的实战经验,要想让这个组合算法跑出好效果,有几个细节必须注意:
- 学习率与优化器:对于大多数问题,Adam优化器搭配
1e-3到1e-4的学习率是个不错的起点。学习率太大容易震荡,太小则学习缓慢。 - 软更新系数τ:
tau参数控制目标网络更新的速度。通常设置一个很小的值,比如0.005或0.01。这意味着每次主网络更新后,目标网络只向主网络靠近一小步。这个“慢更新”机制是稳定训练的关键,我试过直接硬拷贝(tau=1),训练曲线会抖动得非常厉害。 - 探索率ε的衰减:探索(Exploration)和利用(Exploitation)的平衡至关重要。开始时需要高探索率(如
epsilon=1.0)去广泛尝试,然后随着训练逐渐衰减(如乘以0.995每回合),直到一个很小的下限(如0.01),让智能体后期专注于利用学到的好策略。 - 批大小(Batch Size):
batch_size通常设为32、64或128。太小了梯度估计噪声大,太大了计算慢且容易过拟合。对于简单的环境,64是个稳妥的选择。 - 奖励缩放(Reward Scaling):如果环境给出的原始奖励数值范围很大(比如有时+1000,有时-0.1),最好对奖励进行适当的缩放或归一化,这能帮助网络更稳定地学习。一个简单的做法是将所有奖励除以一个常数,使其大致落在[-1, 1]区间。
- 耐心,耐心,还是耐心:深度强化学习的训练过程往往不是单调上升的,而是会有波动和平台期。不要看到几十个回合没进步就放弃。有时智能体是在积累经验,可能到几百个回合后会突然“开窍”,分数大幅跃升。
把这些点都注意到,你的Dueling Double DQN智能体就能在大多数经典控制任务中表现出色了。当然,每个具体环境可能还需要一些微调,但这套框架和思路为你提供了一个强大而坚实的起点。

6173

被折叠的 条评论
为什么被折叠?



