CS285 深度强化学习(六):Value-based 方法——DQN 家族

0. 写在前面

本篇概要

前面几篇我们深入学习了策略梯度和 Actor-Critic 方法。这一篇我们暂时离开策略优化,转向另一个重要的算法家族:基于价值的方法(Value-based Methods)。

2013 年,DeepMind 的 DQN(Deep Q-Network)在 Atari 游戏上取得了惊人的成功,直接从像素输入学习控制策略,达到甚至超越人类水平。这篇论文被认为是深度强化学习的开山之作,开启了一个新时代。

这一篇我们将学习:

  • Q-learning 的基本原理
  • DQN 的两个核心技术:经验回放和目标网络
  • DQN 的各种改进:Double DQN、Dueling DQN、Prioritized Experience Replay
  • HW3 作业解析

系列导航

序号标题状态
1开篇:强化学习的世界观✅ 已完成
2模仿学习:让 AI 学会"抄作业"✅ 已完成
3策略梯度(上):从直觉到公式✅ 已完成
4策略梯度(下):让训练更稳定✅ 已完成
5Actor-Critic:双剑合璧✅ 已完成
6Value-based 方法:DQN 家族(本文)📖 当前
7Model-based RL(上):学会想象⏳ 待更新
………

前置知识

  • 阅读过本系列第一篇(MDP、贝尔曼方程)
  • 了解神经网络和梯度下降

预计阅读时间

约 35 分钟


1. 从 Q-learning 说起

1.1 回顾:Q 函数与最优策略

在第一篇中,我们定义了动作价值函数 Q π ( s , a ) Q^\pi(s, a) Qπ(s,a):在状态 s s s 采取动作 a a a,之后遵循策略 π \pi π 的期望回报。

最优 Q 函数 Q ∗ ( s , a ) Q^*(s, a) Q∗(s,a) 满足贝尔曼最优方程:

Q ∗ ( s , a ) = R ( s , a ) + γ ∑ s ′ P ( s ′ ∣ s , a ) max ⁡ a ′ Q ∗ ( s ′ , a ′ ) Q^*(s, a) = R(s, a) + \gamma \sum_{s'} P(s'|s, a) \max_{a'} Q^*(s', a') Q∗(s,a)=R(s,a)+γs′∑​P(s′∣s,a)a′max​Q∗(s′,a′)

一旦我们知道了 Q ∗ Q^* Q∗,最优策略就是简单地选择 Q 值最大的动作:

π ∗ ( s ) = arg ⁡ max ⁡ a Q ∗ ( s , a ) \pi^*(s) = \arg\max_a Q^*(s, a) π∗(s)=argamax​Q∗(s,a)

1.2 表格型 Q-learning

Q-learning 是一种经典的无模型算法,通过迭代更新来逼近最优 Q 函数。

核心更新规则:

Q ( s t , a t ) ← Q ( s t , a t ) + α [ r t + γ max ⁡ a ′ Q ( s t + 1 , a ′ ) − Q ( s t , a t ) ] Q(s_t, a_t) \leftarrow Q(s_t, a_t) + \alpha \left[ r_t + \gamma \max_{a'} Q(s_{t+1}, a') - Q(s_t, a_t) \right] Q(st​,at​)←Q(st​,at​)+α[rt​+γa′max​Q(st+1​,a′)−Q(st​,at​)]

其中:

  • α \alpha α 是学习率
  • r t + γ max ⁡ a ′ Q ( s t + 1 , a ′ ) r_t + \gamma \max_{a'} Q(s_{t+1}, a') rt​+γmaxa′​Q(st+1​,a′) 是 TD 目标(一步 bootstrap 估计)
  • r t + γ max ⁡ a ′ Q ( s t + 1 , a ′ ) − Q ( s t , a t ) r_t + \gamma \max_{a'} Q(s_{t+1}, a') - Q(s_t, a_t) rt​+γmaxa′​Q(st+1​,a′)−Q(st​,at​) 是 TD 误差

Q-learning 更新

当前估计
Q(s,a)

TD 误差
Target - Current

TD 目标
r + γ max Q(s',a')

更新
Q ← Q + α·Error

1.3 Q-learning 的特点

Off-policy:Q-learning 是 off-policy 的,因为它学习的是最优策略 max ⁡ a Q \max_a Q maxa​Q,而不管实际采样时用的是什么策略(行为策略)。

探索策略:通常使用 ε-greedy 策略来采样:

a = { arg ⁡ max ⁡ a Q ( s , a ) 以概率  1 − ϵ 随机动作 以概率  ϵ a = \begin{cases} \arg\max_a Q(s, a) & \text{以概率 } 1-\epsilon \\ \text{随机动作} & \text{以概率 } \epsilon \end{cases} a={argmaxa​Q(s,a)随机动作​以概率 1−ϵ以概率 ϵ​

1.4 表格型方法的局限

表格型 Q-learning 需要为每个 ( s , a ) (s, a) (s,a) 对维护一个 Q 值。

问题:当状态空间很大或连续时,这是不可行的。

  • Atari 游戏: 210 × 160 × 3 210 \times 160 \times 3 210×160×3 的图像输入,状态数量天文数字
  • 机器人控制:连续的关节角度,无限多的状态

解决方案:用函数逼近!用神经网络 Q θ ( s , a ) Q_\theta(s, a) Qθ​(s,a) 来近似 Q 函数。


2. DQN:深度 Q 网络

2.1 从表格到神经网络

最直接的想法:用神经网络 Q θ ( s , a ) Q_\theta(s, a) Qθ​(s,a) 来代替 Q 表格。

损失函数(基于 TD 误差):

L ( θ ) = E [ ( r + γ max ⁡ a ′ Q θ ( s ′ , a ′ ) − Q θ ( s , a ) ) 2 ] L(\theta) = \mathbb{E} \left[ \left( r + \gamma \max_{a'} Q_\theta(s', a') - Q_\theta(s, a) \right)^2 \right] L(θ)=E[(r+γa′max​Qθ​(s′,a′)−Qθ​(s,a))2]

直接优化这个损失函数,应该就可以学到好的 Q 函数了吧?

然而,直接这样做会失败! 原因是两个问题:

2.2 问题一:数据相关性

在线采样的连续经验是高度相关的: ( s 1 , a 1 , r 1 , s 2 ) , ( s 2 , a 2 , r 2 , s 3 ) , … (s_1, a_1, r_1, s_2), (s_2, a_2, r_2, s_3), \ldots (s1​,a1​,r1​,s2​),(s2​,a2​,r2​,s3​),…

相邻的转移来自同一条轨迹,状态非常相似。

为什么有问题?

神经网络的训练假设数据是独立同分布(i.i.d.)的。相关数据会导致:

  • 网络过拟合到最近的经验
  • 训练不稳定
  • 容易"灾难性遗忘"之前学到的东西

2.3 问题二:目标不稳定

注意损失函数中的 TD 目标: y = r + γ max ⁡ a ′ Q θ ( s ′ , a ′ ) y = r + \gamma \max_{a'} Q_\theta(s', a') y=r+γmaxa′​Qθ​(s′,a′)

这个目标依赖于当前的网络参数 θ \theta θ!

当我们更新 θ \theta θ 时,目标 y y y 也在变化——我们在追逐一个移动的目标。

不稳定的学习

Q_θ₁

目标 y₁

更新

Q_θ₂

目标 y₂ (变了!)

更新

Q_θ₃

目标 y₃ (又变了!)

这就像是:你在追一个人,但每次你迈出一步,那个人也移动了。你可能永远追不上,或者在原地打转。

2.4 DQN 的两个关键技术

DeepMind 在 2013/2015 年的论文中提出了两个简单但极其有效的技术来解决这些问题:

深度 Q 网络的问题

问题1: 数据相关性

问题2: 目标不稳定

解决方案 1:
经验回放
(Experience Replay)

解决方案 2:
目标网络
(Target Network)

DQN


3. 经验回放(Experience Replay)

3.1 核心思想

经验回放:把交互经验存储在一个回放缓冲区(Replay Buffer) 中,训练时从中随机采样。

训练

回放缓冲区

数据收集

随机采样 batch

环境

经验 (s,a,r,s',done)

Replay Buffer
容量 N (如 100万)

训练 Q 网络

3.2 为什么有效?

  1. 打破相关性:随机采样的 batch 中,经验来自不同的时间点和轨迹,近似 i.i.d.

  2. 数据复用:每个经验可以被多次使用,提高样本效率

  3. 平滑分布变化:buffer 中包含旧策略的经验,使训练分布变化更平滑

3.3 实现

import numpy as np
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"""
        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)

3.4 注意事项

  • 缓冲区大小:通常设为 10 5 10^5 105 到 10 6 10^6 106。太小会丢失有价值的经验,太大会占用太多内存
  • 最小采样数:训练前需要先收集足够的经验(如 10000 步)
  • Off-policy 的必然:使用经验回放意味着训练数据来自旧策略,所以必须使用 off-policy 算法

4. 目标网络(Target Network)

4.1 核心思想

目标网络:维护一个参数固定的网络 Q θ − Q_{\theta^-} Qθ−​ 来计算 TD 目标,定期从主网络同步参数。

TD 目标变为:

y = r + γ max ⁡ a ′ Q θ − ( s ′ , a ′ ) y = r + \gamma \max_{a'} Q_{\theta^-}(s', a') y=r+γa′max​Qθ−​(s′,a′)

注意:目标使用 θ − \theta^- θ−(固定),而不是 θ \theta θ(正在更新)。

4.2 参数更新方式

硬更新(Hard Update):每隔 C C C 步,完全复制参数

θ − ← θ 每  C  步 \theta^- \leftarrow \theta \quad \text{每 } C \text{ 步} θ−←θ每 C 步

软更新(Soft Update):每步进行指数移动平均

θ − ← τ θ + ( 1 − τ ) θ − 每步 \theta^- \leftarrow \tau \theta + (1 - \tau) \theta^- \quad \text{每步} θ−←τθ+(1−τ)θ−每步

其中 τ \tau τ 是一个小的值(如 0.005)。

软更新

每步: θ⁻ ← 0.995θ⁻ + 0.005θ

参数缓慢、平滑地变化

硬更新

步骤 1-999: θ⁻ 不变

步骤 1000: θ⁻ ← θ

步骤 1001-1999: θ⁻ 不变

4.3 为什么有效?

  1. 目标稳定:在 C C C 步内(或软更新时),目标基本不变,训练更稳定

  2. 减少震荡:避免 Q 值估计和目标之间的"追逐"

  3. 类似于监督学习:在短期内,问题变成了一个(近似)固定目标的回归问题

4.4 实现

import copy

class DQN:
    def __init__(self, ...):
        # 主网络
        self.q_network = QNetwork(...)

        # 目标网络(初始化为主网络的副本)
        self.target_network = copy.deepcopy(self.q_network)

        # 冻结目标网络的梯度
        for param in self.target_network.parameters():
            param.requires_grad = False

    def hard_update_target(self):
        """硬更新目标网络"""
        self.target_network.load_state_dict(self.q_network.state_dict())

    def soft_update_target(self, tau=0.005):
        """软更新目标网络"""
        for target_param, param in zip(
            self.target_network.parameters(),
            self.q_network.parameters()
        ):
            target_param.data.copy_(
                tau * param.data + (1 - tau) * target_param.data
            )

5. DQN 完整算法

5.1 算法伪代码

算法:DQN (Deep Q-Network)

初始化:
    - Q 网络 Q_θ,随机参数 θ
    - 目标网络 Q_θ⁻,参数 θ⁻ ← θ
    - 回放缓冲区 D,容量 N
    - 探索率 ε

for episode = 1 to M:
    初始化状态 s₁

    for t = 1 to T:
        # 1. 选择动作(ε-greedy)
        以概率 ε 选择随机动作 a_t
        否则 a_t = argmax_a Q_θ(s_t, a)

        # 2. 执行动作,观察奖励和下一状态
        执行 a_t,得到 r_t, s_{t+1}

        # 3. 存储经验
        将 (s_t, a_t, r_t, s_{t+1}, done) 存入 D

        # 4. 采样并训练
        从 D 中随机采样 batch {(s_j, a_j, r_j, s_j', done_j)}

        计算目标:
        y_j = r_j                           如果 done_j
        y_j = r_j + γ max_a' Q_θ⁻(s_j', a')  否则

        计算损失:
        L = (1/batch_size) Σ (y_j - Q_θ(s_j, a_j))²

        梯度下降更新 θ

        # 5. 更新目标网络
        每 C 步:θ⁻ ← θ

5.2 PyTorch 完整实现

import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
import copy

class QNetwork(nn.Module):
    """Q 网络"""

    def __init__(self, obs_dim, act_dim, hidden_dim=256):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(obs_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, act_dim)
        )

    def forward(self, obs):
        """返回所有动作的 Q 值"""
        return self.net(obs)


class DQNAgent:
    """DQN 智能体"""

    def __init__(
        self,
        obs_dim,
        act_dim,
        lr=1e-4,
        gamma=0.99,
        epsilon_start=1.0,
        epsilon_end=0.01,
        epsilon_decay=100000,
        buffer_size=100000,
        batch_size=32,
        target_update_freq=1000,
        device='cpu'
    ):
        self.act_dim = act_dim
        self.gamma = gamma
        self.batch_size = batch_size
        self.target_update_freq = target_update_freq
        self.device = device

        # 探索参数
        self.epsilon_start = epsilon_start
        self.epsilon_end = epsilon_end
        self.epsilon_decay = epsilon_decay

        # 网络
        self.q_network = QNetwork(obs_dim, act_dim).to(device)
        self.target_network = copy.deepcopy(self.q_network)

        # 冻结目标网络
        for param in self.target_network.parameters():
            param.requires_grad = False

        # 优化器
        self.optimizer = optim.Adam(self.q_network.parameters(), lr=lr)

        # 回放缓冲区
        self.buffer = ReplayBuffer(buffer_size)

        # 计数器
        self.train_step = 0

    def get_epsilon(self, step):
        """计算当前的 ε(线性衰减)"""
        epsilon = self.epsilon_end + (self.epsilon_start - self.epsilon_end) * \
                  max(0, (self.epsilon_decay - step) / self.epsilon_decay)
        return epsilon

    def select_action(self, obs, step, eval_mode=False):
        """选择动作(ε-greedy)"""
        if eval_mode:
            epsilon = 0.0
        else:
            epsilon = self.get_epsilon(step)

        if np.random.random() < epsilon:
            return np.random.randint(self.act_dim)
        else:
            with torch.no_grad():
                obs_tensor = torch.FloatTensor(obs).unsqueeze(0).to(self.device)
                q_values = self.q_network(obs_tensor)
                return q_values.argmax(dim=1).item()

    def update(self):
        """执行一次训练更新"""
        if len(self.buffer) < self.batch_size:
            return None

        # 采样
        states, actions, rewards, next_states, dones = self.buffer.sample(self.batch_size)

        states = torch.FloatTensor(states).to(self.device)
        actions = torch.LongTensor(actions).to(self.device)
        rewards = torch.FloatTensor(rewards).to(self.device)
        next_states = torch.FloatTensor(next_states).to(self.device)
        dones = torch.FloatTensor(dones).to(self.device)

        # 计算当前 Q 值
        q_values = self.q_network(states)
        q_values = q_values.gather(1, actions.unsqueeze(1)).squeeze(1)

        # 计算目标 Q 值
        with torch.no_grad():
            next_q_values = self.target_network(next_states)
            max_next_q = next_q_values.max(dim=1)[0]
            targets = rewards + self.gamma * max_next_q * (1 - dones)

        # 计算损失
        loss = nn.functional.mse_loss(q_values, targets)

        # 更新
        self.optimizer.zero_grad()
        loss.backward()
        # 梯度裁剪(可选但推荐)
        nn.utils.clip_grad_norm_(self.q_network.parameters(), 10)
        self.optimizer.step()

        # 更新目标网络
        self.train_step += 1
        if self.train_step % self.target_update_freq == 0:
            self.target_network.load_state_dict(self.q_network.state_dict())

        return loss.item()


def train_dqn(env, agent, total_steps=500000, start_learning=10000):
    """训练 DQN"""
    episode_rewards = []
    episode_reward = 0
    obs = env.reset()

    for step in range(total_steps):
        # 选择并执行动作
        action = agent.select_action(obs, step)
        next_obs, reward, done, info = env.step(action)

        # 存储经验
        agent.buffer.push(obs, action, reward, next_obs, done)
        episode_reward += reward

        if done:
            episode_rewards.append(episode_reward)
            episode_reward = 0
            obs = env.reset()
        else:
            obs = next_obs

        # 训练
        if step >= start_learning:
            loss = agent.update()

        # 打印进度
        if step % 10000 == 0 and len(episode_rewards) > 0:
            avg_reward = np.mean(episode_rewards[-100:])
            epsilon = agent.get_epsilon(step)
            print(f"Step {step}, Avg Reward: {avg_reward:.2f}, Epsilon: {epsilon:.3f}")

    return episode_rewards

6. DQN 的问题:过估计

6.1 什么是过估计?

DQN 使用 max ⁡ a ′ Q ( s ′ , a ′ ) \max_{a'} Q(s', a') maxa′​Q(s′,a′) 来估计下一状态的价值。

问题:由于神经网络的逼近误差,某些动作的 Q 值可能被高估。而 max ⁡ \max max 操作会选择性地挑选这些高估的值!

E [ max ⁡ a Q ( s , a ) ] ≥ max ⁡ a E [ Q ( s , a ) ] \mathbb{E}[\max_a Q(s, a)] \geq \max_a \mathbb{E}[Q(s, a)] E[amax​Q(s,a)]≥amax​E[Q(s,a)]

随着训练进行,这种过估计会通过 bootstrap 传播和积累。

6.2 过估计的影响

结果

估计 Q 值(有噪声)

真实 Q 值

Q*(s,a₁) = 10

Q*(s,a₂) = 8

Q*(s,a₃) = 9

Q̂(s,a₁) = 9 (-1)

Q̂(s,a₂) = 12 (+4) ← max!

Q̂(s,a₃) = 8 (-1)

选择 a₂ (次优)

目标值过高

误差传播

后果:

  • 学到次优策略(选择了被高估的动作)
  • Q 值膨胀,训练不稳定
  • 性能下降

7. Double DQN:解决过估计

7.1 核心思想

Double DQN 的关键洞察:过估计来自于用同一个网络既选择动作又评估价值。

解决方案:解耦选择和评估

  • 选择动作:用主网络 Q θ Q_\theta Qθ​
  • 评估价值:用目标网络 Q θ − Q_{\theta^-} Qθ−​

7.2 公式对比

DQN:
y = r + γ max ⁡ a ′ Q θ − ( s ′ , a ′ ) y = r + \gamma \max_{a'} Q_{\theta^-}(s', a') y=r+γa′max​Qθ−​(s′,a′)

目标网络既选择 a ′ = arg ⁡ max ⁡ a' = \arg\max a′=argmax,又评估 Q ( s ′ , a ′ ) Q(s', a') Q(s′,a′)。

Double DQN:
y = r + γ Q θ − ( s ′ , arg ⁡ max ⁡ a ′ Q θ ( s ′ , a ′ ) ) y = r + \gamma Q_{\theta^-}(s', \arg\max_{a'} Q_\theta(s', a')) y=r+γQθ−​(s′,arga′max​Qθ​(s′,a′))

主网络选择动作,目标网络评估价值。

Double DQN

主网络 Q_θ

选择: argmax Q_θ

目标网络 Q_θ⁻

评估: Q_θ⁻

DQN

目标网络 Q_θ⁻

选择: argmax Q_θ⁻

评估: Q_θ⁻

7.3 为什么有效?

如果主网络高估了某个动作 a a a,目标网络不一定也高估它。反之亦然。

通过使用两个不同的网络,高估的概率大大降低。

7.4 实现修改

只需要修改目标计算部分:

def compute_double_dqn_target(self, next_states, rewards, dones):
    """计算 Double DQN 目标"""
    with torch.no_grad():
        # 用主网络选择动作
        next_q_values = self.q_network(next_states)
        best_actions = next_q_values.argmax(dim=1)

        # 用目标网络评估这些动作
        next_q_target = self.target_network(next_states)
        max_next_q = next_q_target.gather(1, best_actions.unsqueeze(1)).squeeze(1)

        targets = rewards + self.gamma * max_next_q * (1 - dones)

    return targets

8. Dueling DQN:更好的网络架构

8.1 核心思想

Dueling DQN 提出了一种新的网络架构,将 Q 值分解为两部分:

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

其中:

  • V ( s ) V(s) V(s):状态价值,“这个状态有多好”
  • A ( s , a ) A(s, a) A(s,a):优势函数,“这个动作比平均好多少”

8.2 为什么要分解?

直觉:在很多状态下,动作的选择并不重要(比如游戏中的空闲状态)。这时候学习 V ( s ) V(s) V(s) 比学习每个 Q ( s , a ) Q(s, a) Q(s,a) 更有效。

例子:在 Atari 游戏的某些帧中,不管采取什么动作,结果都差不多。Dueling DQN 可以快速学到这些状态的价值,而不需要尝试每个动作。

8.3 网络架构

输入状态 s

共享特征层

价值流
V(s)

优势流
A(s,a)

组合

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

8.4 实现细节

直接使用 Q = V + A Q = V + A Q=V+A 有一个问题:给定 Q Q Q, V V V 和 A A A 不唯一(可以给 V V V 加常数, A A A 减同样的常数)。

解决方案:让 A A A 的均值为 0:

Q ( s , a ) = V ( s ) + ( A ( s , a ) − 1 ∣ A ∣ ∑ a ′ A ( s , a ′ ) ) Q(s, a) = V(s) + \left( A(s, a) - \frac{1}{|A|} \sum_{a'} A(s, a') \right) Q(s,a)=V(s)+(A(s,a)−∣A∣1​a′∑​A(s,a′))

或者使用 max ⁡ \max max:

Q ( s , a ) = V ( s ) + ( A ( s , a ) − max ⁡ a ′ A ( s , a ′ ) ) Q(s, a) = V(s) + \left( A(s, a) - \max_{a'} A(s, a') \right) Q(s,a)=V(s)+(A(s,a)−a′max​A(s,a′))

8.5 PyTorch 实现

class DuelingQNetwork(nn.Module):
    """Dueling DQN 网络"""

    def __init__(self, obs_dim, act_dim, hidden_dim=256):
        super().__init__()

        # 共享特征层
        self.features = nn.Sequential(
            nn.Linear(obs_dim, hidden_dim),
            nn.ReLU(),
        )

        # 价值流
        self.value_stream = nn.Sequential(
            nn.Linear(hidden_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, 1)
        )

        # 优势流
        self.advantage_stream = nn.Sequential(
            nn.Linear(hidden_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, act_dim)
        )

    def forward(self, obs):
        features = self.features(obs)

        value = self.value_stream(features)  # (batch, 1)
        advantage = self.advantage_stream(features)  # (batch, act_dim)

        # 组合:Q = V + (A - mean(A))
        q_values = value + advantage - advantage.mean(dim=1, keepdim=True)

        return q_values

9. 优先经验回放(Prioritized Experience Replay)

9.1 核心思想

基本的经验回放均匀随机采样。但直觉上,有些经验比其他经验更"有价值"——那些 TD 误差大的经验。

优先经验回放(PER):根据 TD 误差的大小来决定采样概率,TD 误差大的经验更可能被采样。

9.2 采样概率

经验 i i i 的采样概率:

P ( i ) = p i α ∑ k p k α P(i) = \frac{p_i^\alpha}{\sum_k p_k^\alpha} P(i)=∑k​pkα​piα​​

其中 p i = ∣ δ i ∣ + ϵ p_i = |\delta_i| + \epsilon pi​=∣δi​∣+ϵ 是优先级(TD 误差的绝对值加一个小常数)。

  • α = 0 \alpha = 0 α=0:退化为均匀采样
  • α = 1 \alpha = 1 α=1:完全按优先级采样

9.3 重要性采样校正

非均匀采样会引入偏差。需要用重要性采样权重来校正:

w i = ( 1 N ⋅ 1 P ( i ) ) β w_i = \left( \frac{1}{N} \cdot \frac{1}{P(i)} \right)^\beta wi​=(N1​⋅P(i)1​)β

其中 β \beta β 从一个小值(如 0.4)逐渐增加到 1。

更新规则:

L = 1 N ∑ i w i ⋅ ( y i − Q ( s i , a i ) ) 2 L = \frac{1}{N} \sum_i w_i \cdot (y_i - Q(s_i, a_i))^2 L=N1​i∑​wi​⋅(yi​−Q(si​,ai​))2

9.4 实现要点

高效实现 PER 需要使用Sum Tree 数据结构,可以在 O ( log ⁡ N ) O(\log N) O(logN) 时间内完成采样和更新。

class PrioritizedReplayBuffer:
    """优先经验回放(简化版)"""

    def __init__(self, capacity, alpha=0.6):
        self.capacity = capacity
        self.alpha = alpha
        self.buffer = []
        self.priorities = np.zeros(capacity)
        self.position = 0

    def push(self, *experience):
        # 新经验给予最高优先级
        max_priority = self.priorities.max() if self.buffer else 1.0

        if len(self.buffer) < self.capacity:
            self.buffer.append(experience)
        else:
            self.buffer[self.position] = experience

        self.priorities[self.position] = max_priority
        self.position = (self.position + 1) % self.capacity

    def sample(self, batch_size, beta=0.4):
        # 计算采样概率
        priorities = self.priorities[:len(self.buffer)]
        probs = priorities ** self.alpha
        probs /= probs.sum()

        # 采样
        indices = np.random.choice(len(self.buffer), batch_size, p=probs)
        experiences = [self.buffer[i] for i in indices]

        # 重要性权重
        weights = (len(self.buffer) * probs[indices]) ** (-beta)
        weights /= weights.max()

        return experiences, indices, weights

    def update_priorities(self, indices, td_errors):
        """更新优先级"""
        for idx, td_error in zip(indices, td_errors):
            self.priorities[idx] = abs(td_error) + 1e-6

10. DQN 变体总结

10.1 主要改进对比

改进解决的问题核心思想
经验回放数据相关性存储并随机采样经验
目标网络目标不稳定固定目标网络参数
Double DQNQ 值过估计解耦动作选择和评估
Dueling DQN状态价值学习效率分解 Q 为 V + A
PER采样效率按 TD 误差优先采样

10.2 Rainbow:集大成者

Rainbow DQN(2017)将多种改进组合在一起:

  1. Double DQN
  2. Dueling DQN
  3. Prioritized Experience Replay
  4. Multi-step Learning
  5. Distributional RL (C51)
  6. Noisy Nets

实验表明,这些改进是互补的,组合后效果显著优于单独使用。

原始 DQN

+ Double
+ Dueling
+ PER
+ Multi-step
+ Distributional
+ Noisy Nets

Rainbow DQN


11. HW3 作业解析

11.1 作业概述

HW3 要求实现:

  1. 基本的 DQN
  2. Double DQN
  3. 在 Atari 游戏和 LunarLander 等环境上测试

11.2 关键实现点

(1)Atari 预处理

Atari 游戏的输入是原始像素,需要预处理:

import cv2

class AtariPreprocessor:
    """Atari 预处理"""

    def __init__(self, frame_size=84):
        self.frame_size = frame_size

    def process(self, frame):
        # 转灰度
        gray = cv2.cvtColor(frame, cv2.COLOR_RGB2GRAY)
        # 缩放
        resized = cv2.resize(gray, (self.frame_size, self.frame_size))
        # 归一化
        normalized = resized / 255.0
        return normalized
(2)帧堆叠

单帧图像无法表示运动(比如球的方向),需要堆叠多帧:

from collections import deque

class FrameStack:
    """帧堆叠"""

    def __init__(self, n_frames=4):
        self.n_frames = n_frames
        self.frames = deque(maxlen=n_frames)

    def reset(self, frame):
        for _ in range(self.n_frames):
            self.frames.append(frame)
        return self._get_state()

    def step(self, frame):
        self.frames.append(frame)
        return self._get_state()

    def _get_state(self):
        return np.stack(self.frames, axis=0)  # (4, 84, 84)
(3)卷积网络

处理图像输入需要使用 CNN:

class AtariQNetwork(nn.Module):
    """Atari DQN 网络(Nature DQN 架构)"""

    def __init__(self, n_actions, n_frames=4):
        super().__init__()

        self.conv = nn.Sequential(
            nn.Conv2d(n_frames, 32, kernel_size=8, stride=4),
            nn.ReLU(),
            nn.Conv2d(32, 64, kernel_size=4, stride=2),
            nn.ReLU(),
            nn.Conv2d(64, 64, kernel_size=3, stride=1),
            nn.ReLU(),
        )

        # 计算卷积输出大小
        conv_out_size = self._get_conv_out_size((n_frames, 84, 84))

        self.fc = nn.Sequential(
            nn.Linear(conv_out_size, 512),
            nn.ReLU(),
            nn.Linear(512, n_actions)
        )

    def _get_conv_out_size(self, shape):
        with torch.no_grad():
            dummy = torch.zeros(1, *shape)
            return self.conv(dummy).view(1, -1).size(1)

    def forward(self, obs):
        # obs: (batch, n_frames, 84, 84)
        conv_out = self.conv(obs)
        conv_out = conv_out.view(conv_out.size(0), -1)
        return self.fc(conv_out)

11.3 实验分析

实验 1:DQN vs Double DQN

  • 在 LunarLander 上比较学习曲线
  • 预期:Double DQN 更稳定,最终性能可能更好

实验 2:目标网络更新频率

  • 测试不同的 target_update_freq
  • 预期:太频繁会不稳定,太少会学得慢

实验 3:探索策略

  • 比较不同的 ε 衰减方案
  • 预期:适当的探索对性能很重要

11.4 常见问题

问题可能原因解决方法
Q 值爆炸没有梯度裁剪添加 clip_grad_norm
性能突然下降探索不足调整 ε 衰减
训练很慢batch_size 太小增加 batch_size
不收敛学习率太大降低学习率

12. 论文延伸

核心论文

  1. Playing Atari with Deep Reinforcement Learning (Mnih et al., 2013)

    • DQN 的首篇论文(NIPS Workshop)
    • 开启深度强化学习时代
  2. Human-level Control through Deep Reinforcement Learning (Mnih et al., 2015)

    • DQN 的 Nature 论文
    • 更完整的实验和分析
  3. Deep Reinforcement Learning with Double Q-learning (van Hasselt et al., 2016)

    • Double DQN 论文
    • 分析并解决过估计问题
  4. Dueling Network Architectures for Deep Reinforcement Learning (Wang et al., 2016)

    • Dueling DQN 论文
    • 提出 V + A 分解
  5. Rainbow: Combining Improvements in Deep Reinforcement Learning (Hessel et al., 2018)

    • Rainbow 论文
    • 组合多种改进

推荐阅读

  1. Prioritized Experience Replay (Schaul et al., 2016)

    • PER 论文
    • 优先采样重要经验
  2. Noisy Networks for Exploration (Fortunato et al., 2018)

    • Noisy Nets 论文
    • 参数空间探索

13. 思考题

Q1: Off-policy 的代价

DQN 是 off-policy 算法,可以使用经验回放。但 off-policy 也有代价。思考:

  1. 为什么 Actor-Critic(如 A2C)不能直接使用经验回放?
  2. 有什么方法可以让策略梯度方法也用上历史数据?
💡 提示

关键词:重要性采样(Importance Sampling)

策略梯度的期望是在当前策略下计算的,用旧策略的数据需要校正…

Q2: 目标网络的必要性

有人说:既然 Double DQN 用主网络选动作、目标网络评估,那目标网络是不是就不那么必要了?

分析 Double DQN 中目标网络的作用,它和 DQN 中目标网络的作用相同吗?

💡 提示

考虑两个独立的问题:

  1. 目标稳定性(bootstrap 目标变化)
  2. 过估计(max 操作的偏差)

Double DQN 解决的是哪个问题?

Q3: 连续动作的困难

DQN 只能处理离散动作空间,因为需要计算 max ⁡ a Q ( s , a ) \max_a Q(s, a) maxa​Q(s,a)。

思考:

  1. 如果动作空间是连续的, max ⁡ \max max 操作有什么困难?
  2. 有什么方法可以解决这个问题?(提示:考虑 Actor-Critic)
💡 提示

DDPG(Deep Deterministic Policy Gradient)的思路:

  • 用一个 Actor 网络 μ ( s ) \mu(s) μ(s) 直接输出"最优"动作
  • max ⁡ a Q ( s , a ) ≈ Q ( s , μ ( s ) ) \max_a Q(s,a) \approx Q(s, \mu(s)) maxa​Q(s,a)≈Q(s,μ(s))

参考资料

  • [1] CS 285 Fall 2023 Lecture 7-8: Value Function Methods
  • [2] Mnih, V., et al. (2015). Human-level Control through Deep Reinforcement Learning. Nature, 518(7540), 529-533.
  • [3] van Hasselt, H., Guez, A., & Silver, D. (2016). Deep Reinforcement Learning with Double Q-learning. AAAI.
  • [4] Wang, Z., et al. (2016). Dueling Network Architectures for Deep Reinforcement Learning. ICML.
  • [5] Hessel, M., et al. (2018). Rainbow: Combining Improvements in Deep Reinforcement Learning. AAAI.

下一篇预告:CS285 深度强化学习(七):Model-based RL(上)——学会想象

我们将进入另一个重要的领域:基于模型的强化学习。学习如何让智能体"学会想象"——通过学习环境模型来进行规划,大幅提高样本效率。


📝 本文基于 UC Berkeley CS 285 Fall 2023 课程内容整理

如有错误或建议,欢迎在评论区指出!

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐