上一篇文章中,我们已经介绍了 Q-learning 和 Deep Q-learning 的基本形式。Q-learning 的核心是估计最优动作价值函数 q(s,a)q^*(s,a),再通过贪心策略选择动作;DQN 则是将表格形式的 q(s,a)q(s,a) 替换为神经网络近似函数 q(s,a,w)q(s,a,w)

本文将从 Q-learning 的 Bellman 最优方程出发,回顾 DQN 的目标函数、梯度更新、经验回放、目标网络,以及 Double DQN、Dueling DQN、Prioritized Experience Replay 和 Rainbow DQN 的改进逻辑。

# 从 Q-learning 到 DQN

# 从 Bellman 最优方程回顾 Q-learning

强化学习的目标是找到最优策略 π\pi^*,使智能体在任意状态下都能获得最大期望回报。根据 Bellman 最优方程,最优策略对应的动作价值 q(s,a)q^*(s,a) 满足:

q(s,a)=E[Rt+1+γmaxaq(St+1,a)St=s,At=a]q^*(s,a)=\mathbb{E}\left[R_{t+1}+\gamma \max_{a'} q^*(S_{t+1},a')\mid S_t=s,A_t=a\right]

如果环境模型已知,即知道状态转移概率 p(ss,a)p(s'|s,a) 和奖励分布,那么上式可以写为:

q(s,a)=rp(rs,a)r+γsp(ss,a)maxaq(s,a)q^*(s,a) = \sum_r p(r|s,a)r+\gamma \sum_{s'}p(s'|s,a)\max_{a'}q^*(s',a')

但在大多数强化学习问题中,环境模型未知,我们只能通过与环境交互得到样本 (st,at,rt+1,st+1)(s_t,a_t,r_{t+1},s_{t+1})。于是可以用单个样本近似上面的期望:

q(st,at)rt+1+γmaxaq(st+1,a)q^*(s_t,a_t)\approx r_{t+1}+\gamma \max_{a'}q^*(s_{t+1},a')

这就得到 Q-learning 的更新形式:

qt+1(st,at)=qt(st,at)+α[rt+1+γmaxaqt(st+1,a)qt(st,at)]q_{t+1}(s_t,a_t) = q_t(s_t,a_t) + \alpha \left[ r_{t+1} + \gamma \max_{a'}q_t(s_{t+1},a') - q_t(s_t,a_t) \right]

yt=rt+1+γmaxaqt(st+1,a)y_t =r_{t+1}+\gamma \max_{a'}q_t(s_{t+1},a'),称为 TD target,而 δt=ytqt(st,at)\delta_t = y_t - q_t(s_t,a_t) 称为 TD error。因此,Q-learning 的本质可以理解为:不断让当前估计 qt(st,at)q_t(s_t,a_t) 靠近由 Bellman 最优方程给出的目标 yty_t

# 从表格到函数近似

表格 Q-learning 需要为每个状态动作对维护一个动作价值估计值。如果状态和动作都是有限且数量不大,这种方式非常直接。但现实问题中的状态空间往往很大,甚至是连续的。例如 Atari 游戏中,状态是一帧或多帧图像;机器人控制中,状态可能是关节角度、速度和传感器读数。

此时无法为所有状态建立表格,因此需要使用函数近似:

q(s,a)q^(s,a,w)q(s,a)\approx \hat q(s,a,w)

其中 ww 是函数参数。如果该函数由深度神经网络表示,就得到 DQN。

对于离散动作空间,DQN 通常不把状态和动作同时作为输入,而是输入状态 ss,一次性输出所有动作的价值:

Q(s,;θ)=[Q(s,a1;θ),Q(s,a2;θ),,Q(s,am;θ)]Q(s,\cdot;\theta) = \left[ Q(s,a_1;\theta), Q(s,a_2;\theta), \ldots, Q(s,a_m;\theta) \right]

这样做的好处是,选择动作时只需要一次前向传播:

a=argmaxaiQ(s,ai;θ)a=\arg\max_{a_i}Q(s,a_i;\theta)

注意,DQN 仍然只适合离散动作空间。因为它需要枚举动作并取最大值。如果动作是连续的,maxaQ(s,a)\max_a Q(s,a) 本身就是一个额外的连续优化问题,后续通常需要 DDPG、TD3、SAC 等 Actor-Critic 算法处理。

# DQN 目标函数

使用神经网络近似动作价值后,不能再像表格 Q-learning 那样直接修改某个表格项,而是需要通过梯度下降更新网络参数 θ\theta

根据 Bellman 最优方程,理想状态下应该满足:

Q(s,a;θ)=E[R+γmaxaQ(S,a;θ)S=s,A=a]Q(s,a;\theta) = \mathbb{E} \left[ R+\gamma \max_{a'}Q(S',a';\theta) \mid S=s,A=a \right]

因此可以构造如下平方误差目标:

J(θ)=E[(R+γmaxaQ(S,a;θ)Q(S,A;θ))2]J(\theta) = \mathbb{E} \left[ \left( R+\gamma \max_{a'}Q(S',a';\theta) - Q(S,A;\theta) \right)^2 \right]

为了书写方便,定义 y=R+γmaxaQ(S,a;θ)y=R+\gamma \max_{a'}Q(S',a';\theta),即为 DQN 的训练目标值。这看起来与监督学习中的均方误差非常相似。但这里存在一个重要区别:监督学习中的标签 yy 是固定数据集给出的,而 DQN 中的 yy 由网络自己计算出来。

但麻烦的是,在上面的目标中,θ\theta 同时出现在两处。如果严格对整个目标求梯度,会得到比较复杂的形式,因为目标值本身也会对 θ\theta 求导。因此实际 DQN 采用的是半梯度方法,把 TD target 当作常数,只对当前动作价值 Q(S,A;θ)Q(S,A;\theta) 求梯度。

令:

δ=R+γmaxaQ(S,a;θ)Q(S,A;θ)\delta = R+\gamma \max_{a'}Q(S',a';\theta) - Q(S,A;\theta)

半梯度更新为:

θθ+αδθQ(S,A;θ)\theta \leftarrow \theta + \alpha \delta \nabla_{\theta}Q(S,A;\theta)


# DQN 训练技巧

# DQN 的训练问题

如果直接按照上一节的目标训练神经网络,实际效果往往很差,甚至会发散。这里可以从三个角度理解。

第一是样本之间高度相关。在监督学习中,我们通常假设样本独立同分布。但强化学习数据来自智能体与环境的连续交互:

s0,a0,r1,s1,a1,r2,s2,s_0,a_0,r_1,s_1,a_1,r_2,s_2,\ldots

相邻状态之间高度相关。例如在游戏中,连续两帧画面差异很小;在控制任务中,连续两个时刻的速度和位置也非常接近。如果直接按时间顺序训练,网络会在一段高度相似的数据上反复更新,容易造成参数震荡,也会降低样本利用效率。

第二是训练目标值不断移动。监督学习中的标签是固定的,例如图像分类中一张图片的标签不会因为模型更新而变化。但 DQN 的目标值为 y=R+γmaxaQ(S,a;θ)y=R+\gamma \max_{a'}Q(S',a';\theta)。当 θ\theta 更新后,目标值也会跟着变化。这相当于模型一边追逐目标,一边改变目标本身。

第三是最大值操作会导致过估计。DQN 的训练目标包含最大动作价值的操作。然而神经网络的估计不可避免存在误差。经过 max\max 操作后也更容易选中被高估的动作。这会让 TD target 偏大,从而导致 Q 值系统性高估。

针对上述训练问题,研究人员引入两大核心设计来提高 DQN 训练效率,分别是经验回放和目标网络。

# 经验回放

经验回放(Experience Replay)的做法是将交互样本存入回放池:

D={(si,ai,ri,si,di)}\mathcal{D}=\{(s_i,a_i,r_i,s'_i,d_i)\}

其中 did_i 表示下一状态是否为终止状态。训练时,不直接使用最新样本,而是从回放池中随机采样小批次数据。这样可以打破样本的时间相关性,使训练数据更接近独立同分布。同时,同一条经验可以被多次使用,提高样本效率。

经验回放能够用于 DQN 的关键原因是:Q-learning 是 off-policy 算法。也就是说,用于更新动作价值的样本不要求来自当前正在优化的贪心策略。只要样本包含状态、动作、奖励和下一状态,就可以构造 Bellman 目标:

yi=ri+γ(1di)maxaQ(si,a;θ)y_i = r_i+\gamma(1-d_i)\max_{a'}Q(s'_i,a';\theta)

这里的 (1di)(1-d_i) 用于处理终止状态。如果 sis'_i 是终止状态,则后续回报为 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 引入目标网络,这里会使用到两个网络:

  • 主网络:Q(s,a;θ)Q(s,a;\theta),用于选择动作和被梯度更新。
  • 目标网络:Q(s,a;θ)Q(s,a;\theta^-),用于计算 TD target。

此时目标值写为:

y=R+γ(1d)maxaQ(S,a;θ)y = R+\gamma(1-d)\max_{a'}Q(S',a';\theta^-)

损失函数变为:

L(θ)=E(S,A,R,S,d)D[(R+γ(1d)maxaQ(S,a;θ)Q(S,A;θ))2]L(\theta) = \mathbb{E}_{(S,A,R,S',d)\sim \mathcal{D}} \left[ \left( R+\gamma(1-d)\max_{a'}Q(S',a';\theta^-) - Q(S,A;\theta) \right)^2 \right]

在一次梯度更新中,θ\theta^- 保持不变,只更新 θ\theta。因此:

θL(θ)=2E[(yQ(S,A;θ))θQ(S,A;θ)]\nabla_{\theta}L(\theta) = -2\mathbb{E} \left[ \left( y-Q(S,A;\theta) \right) \nabla_{\theta}Q(S,A;\theta) \right]

目标网络参数并不每一步更新,而是每隔 CC 步从主网络复制一次。这样 TD target 在一段时间内保持稳定,训练过程会明显更平滑。此外,有些算法也会使用软更新的方法来更新目标网络:

θτθ+(1τ)θ\theta^- \leftarrow \tau\theta+(1-\tau)\theta^-

# DQN 训练流程

综合前面的内容,DQN 的训练流程如下:

  1. 初始化主网络 Q(s,a;θ)Q(s,a;\theta)
  2. 初始化目标网络 Q(s,a;θ)Q(s,a;\theta^-),并令 θ=θ\theta^-=\theta
  3. 初始化经验回放池 D\mathcal{D}
  4. 智能体根据 ϵ\epsilon-greedy 策略与环境交互。
  5. 将经验 (s,a,r,s,d)(s,a,r,s',d) 存入回放池。
  6. 从回放池中随机采样 mini-batch。
  7. 使用目标网络计算 TD target。
  8. 使用主网络计算当前动作价值。
  9. 最小化 TD error 对应的损失函数。
  10. 每隔一定步数同步目标网络。

其中,ϵ\epsilon-greedy 策略为:

at={random action,with probability ϵargmaxaQ(st,a;θ),with probability 1ϵa_t = \begin{cases} \text{random action}, & \text{with probability } \epsilon\\ \arg\max_a Q(s_t,a;\theta), & \text{with probability } 1-\epsilon \end{cases}

训练早期通常使用较大的 ϵ\epsilon 鼓励探索,随后逐渐减小。

到这里,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 函数基于神经网络输出的动作价值,采取 ϵ\epsilon-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:

Lδ(x)={12x2,xδδ(x12δ),x>δ\mathcal{L}_{\delta}(x)= \begin{cases} \frac{1}{2}x^2, & |x|\leq \delta\\ \delta(|x|-\frac{1}{2}\delta), & |x|>\delta \end{cases}

相比一般的均方误差,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(s,a)Q(s,a)。但在很多状态下,状态本身的重要性和动作差异是两个不同问题。

例如在某个游戏画面中,智能体已经处在非常危险的位置,那么这个状态本身的价值很低;至于向左还是向右,可能只是相对差异。反过来,在某些状态中,不同动作价值几乎一样,此时强行分别估计每个动作的 Q 值并不高效。

Dueling DQN 将动作价值分解为:

Q(s,a)=V(s)+A(s,a)Q(s,a)=V(s)+A(s,a)

其中:

  • V(s)V(s) 表示状态价值,即状态本身有多好。
  • A(s,a)A(s,a) 表示优势函数,即动作 aa 相比该状态下其他动作有多好。

但是这个分解并不唯一。因为对 V(s)V(s) 加上一个常数,同时对 A(s,a)A(s,a) 减去同一个常数,Q(s,a)Q(s,a) 不变。为了解决这个问题,Dueling DQN 使用如下形式:

Q(s,a)=V(s)+[A(s,a)1AaA(s,a)]Q(s,a) = V(s) + \left[ A(s,a) - \frac{1}{|\mathcal{A}|} \sum_{a'}A(s,a') \right]

这样,每个状态下优势函数的均值被约束为 0,V(s)V(s)A(s,a)A(s,a) 的含义更明确。

对应的网络结构修改如下:

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,也不改变经验回放和目标网络。它只是改变网络如何表示 Q(s,a)Q(s,a),让网络能够更有效地区分 “状态本身好不好” 和 “动作相对好不好”。

# Prioritized Experience Replay

普通经验回放从回放池中均匀采样:

P(i)=1NP(i)=\frac{1}{N}

但不同经验对学习的价值并不相同。一个 TD error 很小的样本,说明网络已经预测得比较准确;一个 TD error 很大的样本,则说明当前网络还没有学好这部分经验。因此,Prioritized Experience Replay(PER)按照 TD error 为不同的样本设置采样优先级:

pi=δi+ϵp_i=|\delta_i|+\epsilon

采样概率为:

P(i)=piαkpkαP(i)=\frac{p_i^{\alpha}}{\sum_kp_k^{\alpha}}

其中 α\alpha 控制优先级的强度。当 α=0\alpha=0 时,退化为均匀采样。

由于这种采样方式改变了训练数据分布,会引入偏差,所以需要重要性采样权重:

wi=(1N1P(i))βw_i= \left( \frac{1}{N}\cdot\frac{1}{P(i)} \right)^{\beta}

通常还会归一化:

wiwimaxjwjw_i\leftarrow\frac{w_i}{\max_jw_j}

最终损失函数变为:

L(θ)=EiP(i)[wi(yiQ(si,ai;θ))2]L(\theta) = \mathbb{E}_{i\sim P(i)} \left[ w_i \left( y_i-Q(s_i,a_i;\theta) \right)^2 \right]

PER 的逻辑可以概括为:优先学习当前错误更大的样本,但用重要性采样权重修正由非均匀采样带来的偏差。


# 总结

DQN 适合处理离散动作空间问题,尤其适合状态维度较高但动作数量有限的环境。例如 Atari 游戏中,状态是图像,动作是有限的摇杆或按钮操作。但 DQN 也有明显局限:

  • DQN 不适合连续动作空间。因为它依赖最大的动作价值来更新网络,而当动作连续时,无法简单枚举所有动作。
  • DQN 对超参数敏感。学习率、回放池大小、目标网络同步频率、batch size、ϵ\epsilon 衰减速度都会影响训练稳定性。
  • DQN 的样本效率并不高。虽然经验回放提高了样本利用率,但复杂环境中仍然需要大量交互数据。
  • DQN 学到的是动作价值函数,策略由 argmaxQ(s,a)\arg\max Q(s,a) 间接给出。对于需要随机策略、连续控制或更稳定策略优化的问题,策略梯度和 Actor-Critic 方法往往更合适。

总而言之,DQN 是理解深度强化学习非常重要的一步。它连接了表格 Q-learning 和后续更复杂的深度强化学习算法,也为理解 Actor-Critic、DDPG、TD3、SAC 等方法打下基础。