上一篇博客中,我们已经介绍了 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=100_000): | |
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) | |
return zip(*batch) |
需要注意,经验回放并不是简单地存数据。它改变了强化学习中数据使用方式,智能体在线收集数据,但网络更新时可以离线地、随机地、多次利用历史经验。
# 目标网络
经验回放解决了样本相关性问题,但还没有解决目标值不断移动的问题。为此,DQN 引入目标网络,这里会使用到两个网络:
- 主网络:,用于选择动作和被梯度更新。
- 目标网络:,用于计算 TD target。
此时目标值写为:
损失函数变为:
在一次梯度更新中, 保持不变,只更新 。因此:
目标网络参数并不每一步更新,而是每隔 步从主网络复制一次。这样 TD target 在一段时间内保持稳定,训练过程会明显更平滑。此外,有些算法也会使用软更新的方法来更新目标网络:
# DQN 训练流程
综合前面的内容,DQN 的训练流程如下:
- 初始化主网络 。
- 初始化目标网络 ,并令 。
- 初始化经验回放池 。
- 智能体根据 -greedy 策略与环境交互。
- 将经验 存入回放池。
- 从回放池中随机采样 mini-batch。
- 使用目标网络计算 TD target。
- 使用主网络计算当前动作价值。
- 最小化 TD error 对应的损失函数。
- 每隔一定步数同步目标网络。
其中,-greedy 策略为:
训练早期通常使用较大的 鼓励探索,随后逐渐减小。
到这里,DQN 的主线已经清楚:Bellman 最优方程给出目标,神经网络负责近似动作价值,经验回放降低样本相关性,目标网络稳定 TD target。
# DQN 代码实践
下面给出一个低维状态、离散动作环境下的 DQN 实现框架。代码以理解算法为目标,因此没有加入复杂工程封装。
这里使用 gymnasium 库中的 CartPole-v1 环境作为示例进行 DQN 代码实践。该环境中,有一辆小车,智能体的任务是通过左右移动保持车上的杆竖直,若杆的倾斜度数过大,或者车子离初始位置左右的偏离程度过大,或者坚持时间到达指定帧数,则游戏结束。

智能体的状态是一个维数为 4 的向量(车的位置,车的速度,杆的角度,杆尖速度),每一维都是连续的,其动作是离散的,动作空间大小为 2(向左移动,向右移动)。在游戏中每坚持一帧,智能体能获得分数为 1 的奖励,坚持时间越长,则最后的分数越高。
首先导入必要库,并实现上一节的 ReplayBuffer (下述代码已略去):
import random | |
from collections import deque | |
from math import cos, pi | |
import gymnasium as gym | |
import numpy as np | |
import torch | |
import torch.nn as nn | |
import torch.nn.functional as F |
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, x): | |
return self.net(x) |
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(): | |
return q_net(state).argmax(dim=1).item() |
下面定义 update_dqn 函数,从经验池中采样一组数据,使用 Q-Net 计算当前样本的动作价值,使用目标网络计算对应的 TD target,并使用 F.smooth_l1_loss 计算损失函数,然后应用梯度下降算法更新 Q-Net 网络参数。
def update_dqn(q_net, target_net, optimizer, replay_buffer, device): | |
states, actions, rewards, next_states, dones = replay_buffer.sample(BATCH_SIZE) | |
states = torch.as_tensor(np.array(list(states)), dtype=torch.float32, device=device) | |
actions = torch.as_tensor(list(actions), dtype=torch.long, device=device).unsqueeze(1) | |
rewards = torch.as_tensor(list(rewards), dtype=torch.float32, device=device).unsqueeze(1) | |
next_states = torch.as_tensor(np.array(list(next_states)), dtype=torch.float32, device=device) | |
dones = torch.as_tensor(list(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 + 0.99 * (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 不参与反向传播:
损失函数使用 Huber loss:
相比一般的均方误差,Huber loss 对过大的 TD error 更稳健。
下面定义训练所需超参数:
EPISODES = 1000 | |
BATCH_SIZE = 128 | |
MIN_BUFFER_SIZE = 1000 | |
LR = 1e-4 | |
MIN_LR = 1e-5 | |
LR_WARMUP_STEPS = 1000 | |
LR_DECAY_STEPS = 100000 | |
EPSILON_START = 0.95 | |
EPSILON_END = 0.001 | |
EPSILON_DECAY_STEPS = 2500 | |
TAU = 0.005 |
为了稳定训练过程,下面 get_lr 函数实现线性预热 + 余弦退火学习率调度:
def get_lr(step): | |
if step < LR_WARMUP_STEPS: | |
return LR * (step + 1) / LR_WARMUP_STEPS | |
progress = min(1.0, (step - LR_WARMUP_STEPS) / LR_DECAY_STEPS) | |
return MIN_LR + (LR - MIN_LR) * 0.5 * (1.0 + cos(pi * progress)) |
最后给出完整的训练代码:
def train(): | |
# 准备环境 | |
env = gym.make("CartPole-v1") | |
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
state_dim = env.observation_space.shape[0] | |
action_dim = 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=LR) | |
replay_buffer = ReplayBuffer() | |
# 记录值 | |
returns, losses = [], [] | |
update_step, global_step = 0, 0 | |
# 训练主流程 | |
for episode in range(EPISODES): | |
state, _ = env.reset() | |
episode_return = 0.0 | |
episode_losses = [] | |
done = False | |
while not done: | |
epsilon = EPSILON_END | |
+ (EPSILON_START - EPSILON_END) * np.exp(-global_step / EPSILON_DECAY_STEPS) | |
action = select_action(q_net, state, epsilon, action_dim, device) | |
next_state, reward, terminated, truncated, _ = env.step(action) | |
done = terminated or truncated | |
replay_buffer.push(state, action, reward, next_state, done) | |
state = next_state | |
episode_return += reward | |
global_step += 1 | |
if len(replay_buffer) >= MIN_BUFFER_SIZE: | |
lr = get_lr(update_step) | |
optimizer.param_groups[0]["lr"] = lr | |
episode_losses.append(update_dqn(q_net, target_net, optimizer, replay_buffer, device)) | |
update_step += 1 | |
for target_param, param in zip(target_net.parameters(), q_net.parameters()): | |
target_param.data.copy_(TAU * param.data + (1.0 - TAU) * target_param.data) | |
returns.append(episode_return) | |
losses.append(np.mean(episode_losses) if episode_losses else np.nan) | |
if (episode + 1) % 10 == 0 or episode == 0: | |
mean_return = np.mean(returns[-20:]) | |
print( | |
f"episode={episode + 1}, return={episode_return:.1f}, " | |
f"mean_return_20={mean_return:.1f}, loss={losses[-1]:.4f}, " | |
f"epsilon={epsilon:.3f}, lr={optimizer.param_groups[0]['lr']:.6f}" | |
) | |
env.close() | |
return returns, losses |
训练曲线如下:

# DQN 的改进算法
# Double DQN
普通 DQN 容易出现动作价值的过高估计(overestimation)。这是因为 DQN 的 TD target 中包含一个 操作:
其中 是目标网络参数。这个最大值操作可以进一步写成:
也就是说, 操作实际上包含两步:
- 动作选择:先找出下一个状态 下价值最大的动作。
- 动作评估:再计算这个动作对应的 Q 值。
在普通 DQN 中,这两步都由同一个目标网络完成。如果 Q 网络的估计存在噪声,那么 操作会倾向于选择那些被偶然高估的动作。这样一来,当前的 也会被更新到偏大的方向。随着训练不断进行,这种正向误差可能会逐步累积,导致 DQN 对动作价值产生系统性的过高估计。对于动作空间较大的任务,这个问题通常更加明显,因为动作越多,越容易出现某个动作被偶然高估并被 选中。
Double DQN 的核心思想是:将动作选择和动作评估拆开,由两套网络分别完成。
具体来说:使用训练网络 选择下一个状态下的最优动作:
使用目标网络 评估这个动作的价值:
因此,Double DQN 的 TD target 写为:
和普通 DQN 相比,区别只在于最大动作的选择由训练网络完成,而动作价值的计算由目标网络完成。
由于训练网络和目标网络的参数不同,某个动作即使被训练网络高估并选中,目标网络也不一定会给出同样高的估计值。因此,Double DQN 可以有效缓解普通 DQN 中由 操作带来的过高估计问题。
从实现角度看,Double DQN 对原始 DQN 的改动很小。原本 DQN 已经包含训练网络和目标网络,只需要在计算 TD target 时,把 “选动作” 和 “算价值” 拆开即可:
with torch.no_grad(): | |
next_actions = q_net(next_states).argmax(dim=1, keepdim=True) | |
next_q_values = target_q_net(next_states).gather(1, next_actions) | |
target_q_values = rewards + gamma * (1 - dones) * next_q_values |
# 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 等方法打下基础。
