上一篇文章中,我们已经介绍了 Q-learning 和 Deep Q-learning 的基本形式。Q-learning 的核心是估计最优动作价值函数 ,再通过贪心策略选择动作;DQN 则是将表格形式的 替换为神经网络近似函数 。
本文将从 Q-learning 的 Bellman 最优方程出发,回顾 DQN 的目标函数、梯度更新、经验回放、目标网络,以及 Double DQN、Dueling DQN、Prioritized Experience Replay 和 Rainbow DQN 的改进逻辑。
# 从 Q-learning 到 DQN
# 从 Bellman 最优方程回顾 Q-learning
强化学习的目标是找到最优策略 ,使智能体在任意状态下都能获得最大期望回报。根据 Bellman 最优方程,最优策略对应的动作价值 满足:
如果环境模型已知,即知道状态转移概率 和奖励分布,那么上式可以写为:
但在大多数强化学习问题中,环境模型未知,我们只能通过与环境交互得到样本 。于是可以用单个样本近似上面的期望:
这就得到 Q-learning 的更新形式:
令 ,称为 TD target,而 称为 TD error。因此,Q-learning 的本质可以理解为:不断让当前估计 靠近由 Bellman 最优方程给出的目标 。
# 从表格到函数近似
表格 Q-learning 需要为每个状态动作对维护一个动作价值估计值。如果状态和动作都是有限且数量不大,这种方式非常直接。但现实问题中的状态空间往往很大,甚至是连续的。例如 Atari 游戏中,状态是一帧或多帧图像;机器人控制中,状态可能是关节角度、速度和传感器读数。
此时无法为所有状态建立表格,因此需要使用函数近似:
其中 是函数参数。如果该函数由深度神经网络表示,就得到 DQN。
对于离散动作空间,DQN 通常不把状态和动作同时作为输入,而是输入状态 ,一次性输出所有动作的价值:
这样做的好处是,选择动作时只需要一次前向传播:
注意,DQN 仍然只适合离散动作空间。因为它需要枚举动作并取最大值。如果动作是连续的, 本身就是一个额外的连续优化问题,后续通常需要 DDPG、TD3、SAC 等 Actor-Critic 算法处理。
# DQN 目标函数
使用神经网络近似动作价值后,不能再像表格 Q-learning 那样直接修改某个表格项,而是需要通过梯度下降更新网络参数 。
根据 Bellman 最优方程,理想状态下应该满足:
因此可以构造如下平方误差目标:
为了书写方便,定义 ,即为 DQN 的训练目标值。这看起来与监督学习中的均方误差非常相似。但这里存在一个重要区别:监督学习中的标签 是固定数据集给出的,而 DQN 中的 由网络自己计算出来。
但麻烦的是,在上面的目标中, 同时出现在两处。如果严格对整个目标求梯度,会得到比较复杂的形式,因为目标值本身也会对 求导。因此实际 DQN 采用的是半梯度方法,把 TD target 当作常数,只对当前动作价值 求梯度。
令:
半梯度更新为:
# DQN 训练技巧
# DQN 的训练问题
如果直接按照上一节的目标训练神经网络,实际效果往往很差,甚至会发散。这里可以从三个角度理解。
第一是样本之间高度相关。在监督学习中,我们通常假设样本独立同分布。但强化学习数据来自智能体与环境的连续交互:
相邻状态之间高度相关。例如在游戏中,连续两帧画面差异很小;在控制任务中,连续两个时刻的速度和位置也非常接近。如果直接按时间顺序训练,网络会在一段高度相似的数据上反复更新,容易造成参数震荡,也会降低样本利用效率。
第二是训练目标值不断移动。监督学习中的标签是固定的,例如图像分类中一张图片的标签不会因为模型更新而变化。但 DQN 的目标值为 。当 更新后,目标值也会跟着变化。这相当于模型一边追逐目标,一边改变目标本身。
第三是最大值操作会导致过估计。DQN 的训练目标包含最大动作价值的操作。然而神经网络的估计不可避免存在误差。经过 操作后也更容易选中被高估的动作。这会让 TD target 偏大,从而导致 Q 值系统性高估。
针对上述训练问题,研究人员引入两大核心设计来提高 DQN 训练效率,分别是经验回放和目标网络。
# 经验回放
经验回放(Experience Replay)的做法是将交互样本存入回放池:
其中 表示下一状态是否为终止状态。训练时,不直接使用最新样本,而是从回放池中随机采样小批次数据。这样可以打破样本的时间相关性,使训练数据更接近独立同分布。同时,同一条经验可以被多次使用,提高样本效率。
经验回放能够用于 DQN 的关键原因是:Q-learning 是 off-policy 算法。也就是说,用于更新动作价值的样本不要求来自当前正在优化的贪心策略。只要样本包含状态、动作、奖励和下一状态,就可以构造 Bellman 目标:
这里的 用于处理终止状态。如果 是终止状态,则后续回报为 0。
经验回放池的简单代码参考实现如下:
from collections import deque | |
import random | |
class ReplayBuffer: | |
def __init__(self, capacity): | |
self.buffer = deque(maxlen=capacity) | |
def __len__(self): | |
return len(self.buffer) | |
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 states, actions, rewards, next_states, dones |
需要注意,经验回放并不是简单地存数据。它改变了强化学习中数据使用方式,智能体在线收集数据,但网络更新时可以离线地、随机地、多次利用历史经验。
# 目标网络
经验回放解决了样本相关性问题,但还没有解决目标值不断移动的问题。为此,DQN 引入目标网络,这里会使用到两个网络:
- 主网络:,用于选择动作和被梯度更新。
- 目标网络:,用于计算 TD target。
此时目标值写为:
损失函数变为:
在一次梯度更新中, 保持不变,只更新 。因此:
目标网络参数并不每一步更新,而是每隔 步从主网络复制一次。这样 TD target 在一段时间内保持稳定,训练过程会明显更平滑。此外,有些算法也会使用软更新的方法来更新目标网络:
# DQN 训练流程
综合前面的内容,DQN 的训练流程如下:
- 初始化主网络 。
- 初始化目标网络 ,并令 。
- 初始化经验回放池 。
- 智能体根据 -greedy 策略与环境交互。
- 将经验 存入回放池。
- 从回放池中随机采样 mini-batch。
- 使用目标网络计算 TD target。
- 使用主网络计算当前动作价值。
- 最小化 TD error 对应的损失函数。
- 每隔一定步数同步目标网络。
其中,-greedy 策略为:
训练早期通常使用较大的 鼓励探索,随后逐渐减小。
到这里,DQN 的主线已经清楚:Bellman 最优方程给出目标,神经网络负责近似动作价值,经验回放降低样本相关性,目标网络稳定 TD target。
# DQN 代码实践
下面给出一个低维状态、离散动作环境下的 DQN 实现框架。代码以理解算法为目标,因此没有加入复杂工程封装。
QNetwork 模型输入环境状态,经由一个简单的 MLP 网络输出每个离散动作对应的动作价值:
import torch | |
import torch.nn as nn | |
class QNetwork(nn.Module): | |
def __init__(self, state_dim, action_dim): | |
super().__init__() | |
self.net = nn.Sequential( | |
nn.Linear(state_dim, 128), | |
nn.ReLU(), | |
nn.Linear(128, 128), | |
nn.ReLU(), | |
nn.Linear(128, action_dim), | |
) | |
def forward(self, state): | |
return self.net(state) |
select_action 函数基于神经网络输出的动作价值,采取 -greedy 策略选择动作:
def select_action(q_net, state, epsilon, action_dim, device): | |
if random.random() < epsilon: | |
return random.randrange(action_dim) | |
state = torch.as_tensor(state, dtype=torch.float32, device=device).unsqueeze(0) | |
with torch.no_grad(): | |
q_values = q_net(state) | |
action = q_values.argmax(dim=1).item() | |
return action |
下面定义 update_dqn 函数,从经验池中采样一组数据,使用 Q-Net 计算当前样本的动作价值,使用目标网络计算对应的 TD target,并使用 F.smooth_l1_loss 计算损失函数,然后应用梯度下降算法更新 Q-Net 网络参数。
import numpy as np | |
import torch.nn.functional as F | |
def update_dqn(q_net, target_net, optimizer, replay_buffer, batch_size, gamma, device): | |
states, actions, rewards, next_states, dones = replay_buffer.sample(batch_size) | |
states = torch.as_tensor(np.array(states), dtype=torch.float32, device=device) | |
actions = torch.as_tensor(actions, dtype=torch.long, device=device).unsqueeze(1) | |
rewards = torch.as_tensor(rewards, dtype=torch.float32, device=device).unsqueeze(1) | |
next_states = torch.as_tensor(np.array(next_states), dtype=torch.float32, device=device) | |
dones = torch.as_tensor(dones, dtype=torch.float32, device=device).unsqueeze(1) | |
q_values = q_net(states).gather(1, actions) | |
with torch.no_grad(): | |
next_q_values = target_net(next_states).max(dim=1, keepdim=True)[0] | |
targets = rewards + gamma * (1.0 - dones) * next_q_values | |
loss = F.smooth_l1_loss(q_values, targets) | |
optimizer.zero_grad() | |
loss.backward() | |
torch.nn.utils.clip_grad_norm_(q_net.parameters(), max_norm=10.0) | |
optimizer.step() | |
return loss.item() |
上述代码中,目标值计算使用 target_net ,并且放在 torch.no_grad() 中,因为 TD target 不参与反向传播:
with torch.no_grad(): | |
next_q_values = target_net(next_states).max(dim=1, keepdim=True)[0] | |
targets = rewards + gamma * (1.0 - dones) * next_q_values |
损失函数使用 Huber loss:
相比一般的均方误差,Huber loss 对过大的 TD error 更稳健。
下面给出完整的训练代码:
def train_dqn(env, episodes=500): | |
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
state_dim, action_dim = env.observation_space.shape[0], env.action_space.n | |
q_net = QNetwork(state_dim, action_dim).to(device) | |
target_net = QNetwork(state_dim, action_dim).to(device) | |
target_net.load_state_dict(q_net.state_dict()) | |
optimizer = torch.optim.Adam(q_net.parameters(), lr=1e-3) | |
replay_buffer = ReplayBuffer(capacity=100_000) | |
gamma = 0.99 | |
batch_size = 64 | |
min_buffer_size = 1000 | |
target_update_interval = 500 | |
epsilon_start = 1.0 | |
epsilon_end = 0.05 | |
epsilon_decay_steps = 20000 | |
global_step = 0 | |
for episode in range(episodes): | |
reset_result = env.reset() | |
state = reset_result[0] if isinstance(reset_result, tuple) else reset_result | |
episode_reward = 0.0 | |
done = False | |
while not done: | |
epsilon = max( | |
epsilon_end, | |
epsilon_start - global_step / epsilon_decay_steps * (epsilon_start - epsilon_end), | |
) | |
action = select_action(q_net, state, epsilon, action_dim, device) | |
step_result = env.step(action) | |
next_state, reward, terminated, truncated, _ = step_result | |
done = terminated or truncated | |
replay_buffer.push(state, action, reward, next_state, done) | |
state = next_state | |
episode_reward += reward | |
global_step += 1 | |
if len(replay_buffer) >= min_buffer_size: | |
update_dqn(q_net, target_net, optimizer, replay_buffer, batch_size, gamma, device) | |
if global_step % target_update_interval == 0: | |
target_net.load_state_dict(q_net.state_dict()) | |
print(f"episode={episode}, reward={episode_reward:.1f}, epsilon={epsilon:.3f}") |
# DQN 的改进算法
# Double DQN
本文前面内容,实际上已经介绍完了 Double DQN,即使用主网络选择下一状态的最优动作,使用目标网络评估这个动作的价值。但为了内容的完整性,这里又开一小节表示占位。
Double DQN 的改动很小,但逻辑非常清晰:主网络负责判断哪个动作更好,目标网络负责给这个动作打分,从而减少同一个估计误差同时影响选择和评估。
# Dueling DQN
普通 DQN 直接输出每个动作的 。但在很多状态下,状态本身的重要性和动作差异是两个不同问题。
例如在某个游戏画面中,智能体已经处在非常危险的位置,那么这个状态本身的价值很低;至于向左还是向右,可能只是相对差异。反过来,在某些状态中,不同动作价值几乎一样,此时强行分别估计每个动作的 Q 值并不高效。
Dueling DQN 将动作价值分解为:
其中:
- 表示状态价值,即状态本身有多好。
- 表示优势函数,即动作 相比该状态下其他动作有多好。
但是这个分解并不唯一。因为对 加上一个常数,同时对 减去同一个常数, 不变。为了解决这个问题,Dueling DQN 使用如下形式:
这样,每个状态下优势函数的均值被约束为 0, 和 的含义更明确。
对应的网络结构修改如下:
class DuelingQNetwork(nn.Module): | |
def __init__(self, state_dim, action_dim): | |
super().__init__() | |
self.feature = nn.Sequential( | |
nn.Linear(state_dim, 128), | |
nn.ReLU(), | |
) | |
self.value_stream = nn.Sequential( | |
nn.Linear(128, 128), | |
nn.ReLU(), | |
nn.Linear(128, 1), | |
) | |
self.advantage_stream = nn.Sequential( | |
nn.Linear(128, 128), | |
nn.ReLU(), | |
nn.Linear(128, action_dim), | |
) | |
def forward(self, state): | |
feature = self.feature(state) | |
value = self.value_stream(feature) | |
advantage = self.advantage_stream(feature) | |
return value + advantage - advantage.mean(dim=1, keepdim=True) |
Dueling DQN 不改变 Bellman target,也不改变经验回放和目标网络。它只是改变网络如何表示 ,让网络能够更有效地区分 “状态本身好不好” 和 “动作相对好不好”。
# Prioritized Experience Replay
普通经验回放从回放池中均匀采样:
但不同经验对学习的价值并不相同。一个 TD error 很小的样本,说明网络已经预测得比较准确;一个 TD error 很大的样本,则说明当前网络还没有学好这部分经验。因此,Prioritized Experience Replay(PER)按照 TD error 为不同的样本设置采样优先级:
采样概率为:
其中 控制优先级的强度。当 时,退化为均匀采样。
由于这种采样方式改变了训练数据分布,会引入偏差,所以需要重要性采样权重:
通常还会归一化:
最终损失函数变为:
PER 的逻辑可以概括为:优先学习当前错误更大的样本,但用重要性采样权重修正由非均匀采样带来的偏差。
# 总结
DQN 适合处理离散动作空间问题,尤其适合状态维度较高但动作数量有限的环境。例如 Atari 游戏中,状态是图像,动作是有限的摇杆或按钮操作。但 DQN 也有明显局限:
- DQN 不适合连续动作空间。因为它依赖最大的动作价值来更新网络,而当动作连续时,无法简单枚举所有动作。
- DQN 对超参数敏感。学习率、回放池大小、目标网络同步频率、batch size、 衰减速度都会影响训练稳定性。
- DQN 的样本效率并不高。虽然经验回放提高了样本利用率,但复杂环境中仍然需要大量交互数据。
- DQN 学到的是动作价值函数,策略由 间接给出。对于需要随机策略、连续控制或更稳定策略优化的问题,策略梯度和 Actor-Critic 方法往往更合适。
总而言之,DQN 是理解深度强化学习非常重要的一步。它连接了表格 Q-learning 和后续更复杂的深度强化学习算法,也为理解 Actor-Critic、DDPG、TD3、SAC 等方法打下基础。
