深入理解深度Q网络DQN:基于python从零实现

DQN是什么玩意儿?

深度Q网络(DQN)是深度强化学习领域里一个超厉害的算法。它把Q学习和深度神经网络巧妙地结合在了一起,专门用来搞定那些状态空间维度特别高、特别复杂的难题。它展示了用函数近似来学习价值函数的超能力,因为传统的表格方法在面对状态空间特别大或者连续不断的状态空间时,就会因为太复杂而搞不定。

为啥要用深度学习来搞Q学习呢?

表格Q学习会把每个状态-动作对都对应一个估计的Q值,存到一个大表格里。这种方法碰到几个超级麻烦的问题:

  1. 维度灾难:状态变量的数量或者每个状态变量可能的值一多,Q表格的大小就会像滚雪球一样指数级增长,很快就会变得在计算上根本搞不定,存都存不下,更别提更新了(比如用像素表示的游戏状态、机器人的传感器读数)。
  2. 连续状态:表格方法根本没法直接处理连续的状态变量,还得先离散化,这不仅会丢失信息,而且离散化之后还是会碰到维度问题。

深度神经网络就像一个超级英雄,它能轻松解决这些问题,因为它是个函数近似器。它不用给每个特定的状态-动作对都存一个值,而是通过一个神经网络 (Q(s, a; \theta)) 来学习从状态(可能还有动作)到Q值的映射,这个映射是由网络的权重 (\theta) 参数化的。这就让网络能够在相似的状态之间泛化知识,特别适合处理大状态空间或者连续状态空间。通常情况下,网络会把状态 (s) 作为输入,然后输出这个状态下所有可能的离散动作对应的估计Q值。

DQN都在哪儿用,怎么用呢?

DQN是一个超级厉害的突破,它让强化学习能够解决以前根本搞不定的问题:

  1. 玩游戏:它最出名的就是用原始像素数据作为输入,在Atari 2600游戏里玩出了超越人类的水平。
  2. 机器人控制:从传感器数据里学习控制策略(虽然很多时候会针对连续动作进行调整)。
  3. 优化问题:在资源分配或者系统控制这种状态很复杂的地方也能用。
  4. 序列决策:在推荐系统或者对话管理这种状态维度很高的领域里也能大显身手。

DQN最适合解决以下这类问题:

  • 状态空间很大、维度很高或者连续不断。
  • 动作空间是离散的。
  • 没有环境动态的模型(无模型)。
  • 可以离线学习(在学习最优策略的时候,可以按照一个不同的探索策略来行动)。

DQN的数学基础

回忆一下Q学习

DQN的基础就是Q学习的更新规则,它旨在逐步改进对动作价值函数 (Q(s, a)) 的估计------从状态 (s) 开始,采取动作 (a),然后按照最优策略继续行动,预期能得到的总折扣未来回报。(Q^*(s, a)) 的贝尔曼最优性方程是:

Q ∗ ( s , a ) = E [ R t + 1 + γ max ⁡ a ′ Q ∗ ( S t + 1 , a ′ ) ∣ S t = s , A t = a ] Q^*(s, a) = \mathbb{E}[R_{t+1} + \gamma \max_{a'} Q^*(S_{t+1}, a') | S_t=s, A_t=a] Q∗(s,a)=E[Rt+1+γa′maxQ∗(St+1,a′)∣St=s,At=a]

表格Q学习的更新规则是这样近似这个方程的:
Q ( s t , a t ) ← Q ( s t , a t ) + α [ r t + γ max ⁡ a ′ Q ( s t + 1 , a ′ ) ⏟ TD目标 − Q ( s t , a t ) ] Q(s_t, a_t) \leftarrow Q(s_t, a_t) + \alpha \left[ \underbrace{r_t + \gamma \max_{a'} Q(s_{t+1}, a')}_{\text{TD目标}} - Q(s_t, a_t) \right] Q(st,at)←Q(st,at)+α TD目标 rt+γa′maxQ(st+1,a′)−Q(st,at)

函数近似

DQN把Q表格换成了一个神经网络 (Q(s, a; \theta))。这个网络通常把状态 (s) 作为输入,然后输出一个向量,包含所有动作对应的Q值,(Q(s, \cdot; \theta))。

DQN的损失函数

训练Q网络就是要最小化预测的Q值 (Q(s_t, a_t; \theta)) 和从贝尔曼方程里得到的目标值之间的差距。一个很直接的目标值就是 (r_t + \gamma \max_{a'} Q(s_{t+1}, a'; \theta))。但是,用同一个变化超快的网络来既做预测又计算目标值,会导致不稳定。DQN引入了两个超重要的技巧:

  1. 经验回放:把代理(agent)经历的转移 ((s_t, a_t, r_t, s_{t+1}, \text{done}_t)) 都存到一个回放缓存 (\mathcal{D}) 里。训练的时候,就从 (\mathcal{D}) 里随机抽样一小批转移。这样就能打破连续样本之间的相关性,让训练过程更加稳定、数据效率更高,有点像监督学习的感觉。

  2. 目标网络:用一个单独的网络 (Q(s, a; \theta^-)) 来计算目标Q值,这个网络的权重 (\theta^-) 是固定的,只会在一定时间间隔后更新(比如,每 (C) 步更新一次),把主Q网络的权重 (\theta) 复制过来 ((\theta^- \leftarrow \theta))。这就为主网络提供了一个更稳定的、可以学习的目标。

从回放缓存里抽样出来的转移 ((s_t, a_t, r_t, s_{t+1}, d_t)) 的目标值 (Y_t) 是:
Y t = { r t 如果 d t 是真(终止状态) r t + γ max ⁡ a ′ Q ( s t + 1 , a ′ ; θ − ) 如果 d t 是假(非终止状态) Y_t = \begin{cases} r_t & \text{如果 } d_t \text{ 是真(终止状态)} \\ r_t + \gamma \max_{a'} Q(s_{t+1}, a'; \theta^-) & \text{如果 } d_t \text{ 是假(非终止状态)} \end{cases} Yt={rtrt+γmaxa′Q(st+1,a′;θ−)如果 dt 是真(终止状态)如果 dt 是假(非终止状态)

损失函数通常是均方误差(MSE)或者Huber损失(Smooth L1),在小批量样本上用梯度下降法来最小化这个损失函数:
L ( θ ) = E ( s , a , r , s ′ , d ) ∼ D [ ( Y j − Q ( s j , a j ; θ ) ) 2 ] L(\theta) = \mathbb{E}_{(s, a, r, s', d) \sim \mathcal{D}} \left[ (Y_j - Q(s_j, a_j; \theta))^2 \right] L(θ)=E(s,a,r,s′,d)∼D[(Yj−Q(sj,aj;θ))2]

DQN的逐步解释

  1. 初始化 :回放缓存 (\mathcal{D})(容量 (N)),主Q网络 (Q(s, a; \theta))(随机权重 (\theta)),目标网络 (Q(s, a; \theta-))((\theta- = \theta)),探索参数 (\epsilon)。
  2. 对于每个剧集
    a. 重置环境,获取初始状态 (s_1)。如果需要的话,对状态进行预处理。
    b. 对于每一步 (t)
    i. 使用基于 (\epsilon)-贪婪的策略,根据 (Q(s_t, \cdot; \theta)) 来选择动作 (a_t)。
    ii. 执行 (a_t),观察奖励 (r_t),下一个状态 (s_{t+1}),完成标志 (d_t)。对 (s_{t+1}) 进行预处理。
    iii. 把 ((s_t, a_t, r_t, s_{t+1}, d_t)) 存到 (\mathcal{D}) 里。
    iv. 抽样小批量 :从 (\mathcal{D}) 里随机抽样。
    v. 计算目标 (Y_j) 使用目标网络 (Q(s, a; \theta^-))。
    vi. 训练主网络 :对 (L(\theta) = (Y_j - Q(s_j, a_j; \theta))^2) 进行梯度下降步骤。
    vii. 更新目标网络 :每 (C) 步,设置 (\theta^- \leftarrow \theta)。
    viii. (s_t \leftarrow s_{t+1})。
    ix. 如果 (d_t),结束剧集。
  3. 重复:直到收敛或者达到最大剧集数。

DQN的关键组成部分

Q网络

  • 核心函数近似器。学习把状态映射到动作价值。
  • 架构取决于状态的表示方式(对于向量用多层感知机MLP,对于图像用卷积神经网络CNN)。
  • 使用非线性激活函数(比如ReLU)。
  • 输出层通常有和离散动作数量一样多的单元,输出原始的Q值(最后没有激活函数)。

经验回放

  • 存储代理的经历。
  • 打破相关性,允许重复使用数据,提高稳定性和样本效率。
  • 使用数据结构比如 deque 来实现。

目标网络

  • 主Q网络的一个副本,更新频率更低。
  • 在计算TD误差的时候提供稳定的、可以学习的目标,防止"移动目标"问题。
  • 对于稳定DQN训练来说至关重要。

探索与利用

  • 通常使用 (\epsilon)-贪婪:以概率 (\epsilon) 随机行动,以概率 (1-\epsilon) 贪婪地(根据Q网络)行动。
  • (\epsilon) 指数衰减:(\epsilon) 随着时间逐渐减小(比如线性或者指数衰减),从一个较高的初始值逐渐减小到一个较低的最终值。

损失函数(MSE/Huber)

  • 测量网络预测和TD目标之间的差异。
  • Huber损失(在PyTorch里叫Smooth L1损失)通常比MSE更受欢迎,因为它对异常值不那么敏感,而异常值在训练早期可能会因为大的TD误差而出现。

超参数

  • DQN的性能对超参数特别敏感,比如学习率、缓冲区大小、批量大小、目标更新频率、折扣因子和 (\epsilon) 衰减计划。通常需要仔细调整。

实际例子:自定义网格世界

因为Gymnasium被禁止使用了,我们就自己创建一个简单的自定义网格世界环境。

环境描述:

  • 网格大小:10x10。
  • 状态 :代理的 (row, col) 位置。为了网络输入,表示为归一化的向量 [row/10, col/10]
  • 动作:4个离散动作:0(上),1(下),2(左),3(右)。
  • 起始状态:(0, 0)。
  • 目标状态:(9, 9)。
  • 奖励
    • 到达目标状态(9, 9)得 +10 分。
    • 撞墙(试图移出网格)扣 -1 分。
    • 其他步骤扣 -0.1 分(小成本鼓励效率)。
  • 终止:当代理到达目标或者达到最大步数时,剧集结束。

设置环境

我们得先导入必要的库,设置好环境,所以咱们现在就动手吧。

python 复制代码
# 导入用于数值计算、绘图和实用功能的必要库
import numpy as np
import matplotlib.pyplot as plt
import random
import math
from collections import namedtuple, deque
from itertools import count
from typing import List, Tuple, Dict, Optional

# 导入 PyTorch 用于构建和训练神经网络
import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F

# 设置设备,如果可用就用 GPU,否则就用 CPU
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"使用设备:{device}")

# 设置随机种子,以便在每次运行时都能得到可重现的结果
seed = 42
random.seed(seed)  # Python 随机模块的种子
np.random.seed(seed)  # NumPy 的种子
torch.manual_seed(seed)  # PyTorch(CPU)的种子
if torch.cuda.is_available():
    torch.cuda.manual_seed_all(seed)  # PyTorch(GPU)的种子

# 为 Jupyter Notebook 启用内联绘图
%matplotlib inline
复制代码
使用设备:cpu

创建自定义环境

第一步就是创建一个自定义的环境,模拟一个简单的网格世界。这个环境有一个 10x10 的网格,代理可以在四个方向上移动:上、下、左、右。代理从左上角(0, 0)开始,目标是到达右下角(9, 9)。根据代理的动作和到达的状态,会给予相应的奖励。奖励定义如下:

  • 到达目标(右下角)得 +10 分
  • 撞墙(超出边界)扣 -1 分
  • 每走一步扣 -0.1 分(鼓励走更短的路径)
python 复制代码
# 自定义网格世界环境
class GridEnvironment:
    """
    一个简单的 10x10 网格世界环境。
    状态:(row, col) 表示为归一化的向量 [row/10, col/10]。
    动作:0(上),1(下),2(左),3(右)。
    奖励:到达目标得 +10 分,撞墙扣 -1 分,每走一步扣 -0.1 分。
    """

    def __init__(self, rows: int = 10, cols: int = 10) -> None:
        """
        初始化网格世界环境。

        参数:
        - rows (int): 网格的行数。
        - cols (int): 网格的列数。
        """
        self.rows: int = rows
        self.cols: int = cols
        self.start_state: Tuple[int, int] = (0, 0)  # 起始位置
        self.goal_state: Tuple[int, int] = (rows - 1, cols - 1)  # 目标位置
        self.state: Tuple[int, int] = self.start_state  # 当前状态
        self.state_dim: int = 2  # 状态由 2 个坐标(row, col)表示
        self.action_dim: int = 4  # 4 个离散动作:上、下、左、右

        # 动作映射:将动作索引映射到 (row_delta, col_delta)
        self.action_map: Dict[int, Tuple[int, int]] = {
            0: (-1, 0),  # 上
            1: (1, 0),   # 下
            2: (0, -1),  # 左
            3: (0, 1)    # 右
        }

    def reset(self) -> torch.Tensor:
        """
        重置环境到起始状态。

        返回:
            torch.Tensor:起始状态作为归一化的张量。
        """
        self.state = self.start_state
        return self._get_state_tensor(self.state)

    def _get_state_tensor(self, state_tuple: Tuple[int, int]) -> torch.Tensor:
        """
        将 (row, col) 元组转换为归一化的张量,供网络使用。

        参数:
        - state_tuple (Tuple[int, int]): 状态表示为元组 (row, col)。

        返回:
            torch.Tensor:归一化的状态作为张量。
        """
        # 将坐标归一化到 0 和 1 之间
        normalized_state: List[float] = [
            state_tuple[0] / (self.rows - 1),
            state_tuple[1] / (self.cols - 1)
        ]
        return torch.tensor(normalized_state, dtype=torch.float32, device=device)

    def step(self, action: int) -> Tuple[torch.Tensor, float, bool]:
        """
        根据给定的动作在环境中执行一步。

        参数:
            action (int): 要执行的动作(0:上,1:下,2:左,3:右)。

        返回:
            Tuple[torch.Tensor, float, bool]:
                - next_state_tensor (torch.Tensor): 下一个状态作为归一化的张量。
                - reward (float): 动作的奖励。
                - done (bool): 剧集是否结束。
        """
        # 如果已经到达目标状态,就返回当前状态
        if self.state == self.goal_state:
            return self._get_state_tensor(self.state), 0.0, True

        # 获取动作对应的行和列的变化量
        dr, dc = self.action_map[action]
        current_row, current_col = self.state
        next_row, next_col = current_row + dr, current_col + dc

        # 默认的步进成本
        reward: float = -0.1
        hit_wall: bool = False

        # 检查动作是否会导致撞墙(超出边界)
        if not (0 <= next_row < self.rows and 0 <= next_col < self.cols):
            # 保持在相同的状态,并受到惩罚
            next_row, next_col = current_row, current_col
            reward = -1.0
            hit_wall = True

        # 更新状态
        self.state = (next_row, next_col)
        next_state_tensor: torch.Tensor = self._get_state_tensor(self.state)

        # 检查是否到达目标状态
        done: bool = (self.state == self.goal_state)
        if done:
            reward = 10.0  # 到达目标的奖励

        return next_state_tensor, reward, done

    def get_action_space_size(self) -> int:
        """
        返回动作空间的大小。

        返回:
            int:可能的动作数量(4)。
        """
        return self.action_dim

    def get_state_dimension(self) -> int:
        """
        返回状态表示的维度。

        返回:
            int:状态的维度(2)。
        """
        return self.state_dim

现在我们已经实现了自定义的网格环境,接下来我们实例化它并验证它的属性和功能。

python 复制代码
# 用 10x10 的网格实例化自定义网格环境
custom_env = GridEnvironment(rows=10, cols=10)

# 获取动作空间的大小和状态维度
n_actions_custom = custom_env.get_action_space_size()
n_observations_custom = custom_env.get_state_dimension()

# 打印环境的基本信息
print(f"自定义网格环境:")
print(f"大小:{custom_env.rows}x{custom_env.cols}")  # 网格大小
print(f"状态维度:{n_observations_custom}")  # 状态维度(2 表示行和列)
print(f"动作维度:{n_actions_custom}")  # 可能的动作数量(4)
print(f"起始状态:{custom_env.start_state}")  # 起始位置
print(f"目标状态:{custom_env.goal_state}")  # 目标位置

# 重置环境并打印起始状态的归一化状态张量
print(f"示例状态张量 (0,0):{custom_env.reset()}")

# 执行一个示例动作:向右移动(动作=3)并打印结果
next_s, r, d = custom_env.step(3)  # 动作 3 对应向右移动
print(f"动作结果 (向右):next_state={next_s.cpu().numpy()}, reward={r}, done={d}")

# 再执行一个示例动作:向上移动(动作=0)并打印结果
# 由于代理已经在最上面一行,所以这个动作会导致撞墙
next_s, r, d = custom_env.step(0)  # 动作 0 对应向上移动
print(f"动作结果 (向上):next_state={next_s.cpu().numpy()}, reward={r}, done={d}")
复制代码
自定义网格环境:
大小:10x10
状态维度:2
动作维度:4
起始状态:(0, 0)
目标状态:(9, 9)
示例状态张量 (0,0):tensor([0., 0.])
动作结果 (向右):next_state=[0.         0.11111111], reward=-0.1, done=False
动作结果 (向上):next_state=[0.         0.11111111], reward=-1.0, done=False

你可以看到,代理在最上面一行,向上移动会导致撞墙,受到 -1.0 的惩罚。下一个状态保持不变。

实现DQN算法

现在,咱们来实现核心部分:Q网络、回放缓存、动作选择策略、优化步骤以及目标网络更新机制。

定义Q网络

我们用 PyTorch 的 nn.Module 定义一个简单的多层感知机(MLP)。

python 复制代码
# 定义 Q 网络架构
class DQN(nn.Module):
    """简单的多层感知机 Q 网络"""
    def __init__(self, n_observations: int, n_actions: int):
        """
        初始化 Q 网络。

        参数:
        - n_observations (int):状态空间的维度。
        - n_actions (int):可能的动作数量。
        """
        super(DQN, self).__init__()
        # 定义网络层
        # 简单的 MLP:输入 -> 隐藏层1 -> ReLU -> 隐藏层2 -> ReLU -> 输出
        self.layer1 = nn.Linear(n_observations, 128) # 输入层
        self.layer2 = nn.Linear(128, 128)           # 隐藏层
        self.layer3 = nn.Linear(128, n_actions)      # 输出层(每个动作的 Q 值)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """
        网络的前向传播。

        参数:
        - x (torch.Tensor):表示状态(或状态批次)的输入张量。

        返回:
        - torch.Tensor:表示每个动作 Q 值的输出张量。
        """
        # 确保输入是浮点张量
        if not isinstance(x, torch.Tensor):
             x = torch.tensor(x, dtype=torch.float32, device=device)
        elif x.dtype != torch.float32:
             x = x.to(dtype=torch.float32)

        # 应用各层并使用 ReLU 激活函数
        x = F.relu(self.layer1(x))
        x = F.relu(self.layer2(x))
        return self.layer3(x) # 输出层没有激活函数(原始 Q 值)

定义回放缓存

我们用 collections.deque 实现高效的存储和 random.sample 进行批量抽样。一个 namedtuple 有助于组织转移数据。

python 复制代码
# 定义存储转移的结构
Transition = namedtuple('Transition',
                        ('state', 'action', 'next_state', 'reward', 'done'))

# 定义回放缓存类
class ReplayMemory(object):
    """存储转移并允许抽样批次"""
    def __init__(self, capacity: int):
        """
        初始化回放缓存。

        参数:
        - capacity (int):最多存储的转移数量。
        """
        self.memory = deque([], maxlen=capacity)

    def push(self, *args):
        """
        保存一个转移。

        参数:
        - *args:转移的各个元素(状态、动作、下一个状态、奖励、完成标志)。
        """
        self.memory.append(Transition(*args))

    def sample(self, batch_size: int) -> List[Transition]:
        """
        从内存中随机抽样一批转移。

        参数:
        - batch_size (int):要抽样的转移数量。

        返回:
        - List[Transition]:包含抽样转移的列表。
        """
        return random.sample(self.memory, batch_size)

    def __len__(self) -> int:
        """返回当前内存的大小"""
        return len(self.memory)

动作选择((\epsilon)-贪婪)

这个函数根据当前状态和 Q 网络,使用 (\epsilon)-贪婪策略来选择动作,用于探索。

python 复制代码
# 动作选择(\(\epsilon\)-贪婪 - 修改为单状态张量输入)
def select_action_custom(state: torch.Tensor,
                         policy_net: nn.Module,
                         epsilon_start: float,
                         epsilon_end: float,
                         epsilon_decay: int,
                         n_actions: int) -> Tuple[torch.Tensor, float]:
    """
    使用 \(\epsilon\)-贪婪策略为单个状态张量选择动作。

    参数:
    - state (torch.Tensor):当前状态作为张量,形状为 [state_dim]。
    - policy_net (nn.Module):用于估计 Q 值的 Q 网络。
    - epsilon_start (float):初始探索率 \(\epsilon\)。
    - epsilon_end (float):衰减后 \(\epsilon\) 的最终值。
    - epsilon_decay (int):\(\epsilon\) 的衰减率(值越大衰减越慢)。
    - n_actions (int):可能的动作数量。

    返回:
    - Tuple[torch.Tensor, float]:
        - 选中的动作作为张量,形状为 [1, 1]。
        - 衰减后的当前 \(\epsilon\) 值。
    """
    global steps_done_custom  # 用于跟踪已执行的步数的全局计数器
    sample = random.random()  # 生成一个随机数,用于 \(\epsilon\)-贪婪决策
    # 根据衰减公式计算当前的 \(\epsilon\) 值
    epsilon_threshold = epsilon_end + (epsilon_start - epsilon_end) * \
        math.exp(-1. * steps_done_custom / epsilon_decay)
    steps_done_custom += 1  # 增加步数计数器

    if sample > epsilon_threshold:
        # 利用:选择 Q 值最高的动作
        with torch.no_grad():
            # 为状态张量添加一个批次维度,使其变为 [1, state_dim]
            state_batch = state.unsqueeze(0)
            # 选择 Q 值最高的动作(输出形状:[1, n_actions])
            action = policy_net(state_batch).max(1)[1].view(1, 1)  # 重塑为 [1, 1]
    else:
        # 探索:选择一个随机动作
        action = torch.tensor([[random.randrange(n_actions)]], device=device, dtype=torch.long)

    return action, epsilon_threshold

优化步骤和选择动作

优化步骤包括从回放缓存中抽样一个小批量,使用目标网络计算目标 Q 值,并通过反向传播更新主 Q 网络。动作选择使用 (\epsilon)-贪婪策略。

python 复制代码
def select_action_custom(
    state: torch.Tensor,
    policy_net: nn.Module,
    epsilon_start: float,
    epsilon_end: float,
    epsilon_decay: int,
    n_actions: int
) -> Tuple[torch.Tensor, float]:
    """
    使用 \(\epsilon\)-贪婪策略为单个状态张量选择动作。

    参数:
    - state (torch.Tensor):当前状态作为张量,形状为 [state_dim]。
    - policy_net (nn.Module):用于估计 Q 值的 Q 网络。
    - epsilon_start (float):初始探索率 \(\epsilon\)。
    - epsilon_end (float):衰减后 \(\epsilon\) 的最终值。
    - epsilon_decay (int):\(\epsilon\) 的衰减率(值越大衰减越慢)。
    - n_actions (int):可能的动作数量。

    返回:
    - Tuple[torch.Tensor, float]:
        - 选中的动作作为张量,形状为 [1, 1]。
        - 衰减后的当前 \(\epsilon\) 值。
    """
    global steps_done_custom  # 用于跟踪已执行的步数的全局计数器

    # 生成一个随机数,用于 \(\epsilon\)-贪婪决策
    sample: float = random.random()

    # 根据衰减公式计算当前的 \(\epsilon\) 值
    epsilon_threshold: float = epsilon_end + (epsilon_start - epsilon_end) * \
        math.exp(-1.0 * steps_done_custom / epsilon_decay)

    # 增加步数计数器
    steps_done_custom += 1

    if sample > epsilon_threshold:
        # 利用:选择 Q 值最高的动作
        with torch.no_grad():
            # 为状态张量添加一个批次维度,使其变为 [1, state_dim]
            state_batch: torch.Tensor = state.unsqueeze(0)
            # 选择 Q 值最高的动作(输出形状:[1, n_actions])
            action: torch.Tensor = policy_net(state_batch).max(1)[1].view(1, 1)  # 重塑为 [1, 1]
    else:
        # 探索:选择一个随机动作
        action = torch.tensor([[random.randrange(n_actions)]], device=device, dtype=torch.long)

    return action, epsilon_threshold

接下来我们继续实现优化步骤,这是DQN算法的核心部分,它通过从回放缓存中抽样一个小批量数据,计算目标Q值,并通过反向传播更新主Q网络。

python 复制代码
def optimize_model(memory: ReplayMemory,
                   policy_net: nn.Module,
                   target_net: nn.Module,
                   optimizer: optim.Optimizer,
                   batch_size: int,
                   gamma: float,
                   criterion: nn.Module = nn.SmoothL1Loss()) -> Optional[float]:
    """
    对策略网络执行一步优化。

    参数:
    - memory (ReplayMemory):存储过去转移的回放缓存。
    - policy_net (nn.Module):正在优化的主Q网络。
    - target_net (nn.Module):用于稳定目标计算的目标Q网络。
    - optimizer (optim.Optimizer):用于更新策略网络的优化器。
    - batch_size (int):每次优化步骤要抽样的转移数量。
    - gamma (float):未来奖励的折扣因子。
    - criterion (nn.Module):使用的损失函数(默认:SmoothL1损失)。

    返回:
    - Optional[float]:优化步骤的损失值,如果没有足够的样本则返回None。
    """
    # 确保回放缓存中有足够的样本以执行优化
    if len(memory) < batch_size:
        return None

    # 从回放缓存中抽样一批转移
    transitions = memory.sample(batch_size)
    batch = Transition(*zip(*transitions))  # 将转移解包为单独的组件

    # 标记非终止状态(不是终端状态的状态)
    non_final_mask = torch.tensor(tuple(map(lambda s: s is not None, batch.next_state)),
                                  device=device, dtype=torch.bool)

    # 将非终止的下一个状态堆叠成张量
    if any(non_final_mask):  # 检查是否有任何非终止状态
        non_final_next_states = torch.stack([s for s in batch.next_state if s is not None])

    # 将当前状态、动作、奖励和完成标志堆叠成张量
    state_batch = torch.stack(batch.state)
    action_batch = torch.cat(batch.action)
    reward_batch = torch.cat(batch.reward)
    done_batch = torch.cat(batch.done)

    # 计算所采取动作的Q(s_t, a)
    state_action_values = policy_net(state_batch).gather(1, action_batch)

    # 使用目标网络计算下一个状态的V(s_{t+1})
    next_state_values = torch.zeros(batch_size, device=device)
    with torch.no_grad():
        if any(non_final_mask):  # 只计算非终止状态
            next_state_values[non_final_mask] = target_net(non_final_next_states).max(1)[0]

    # 使用贝尔曼方程计算预期的Q值
    expected_state_action_values = (next_state_values * gamma) + reward_batch

    # 计算预测和预期Q值之间的损失
    loss = criterion(state_action_values, expected_state_action_values.unsqueeze(1))

    # 执行反向传播和优化
    optimizer.zero_grad()  # 清除之前的梯度
    loss.backward()  # 计算梯度
    torch.nn.utils.clip_grad_value_(policy_net.parameters(), 100)  # 限制梯度以防止梯度爆炸
    optimizer.step()  # 更新策略网络

    return loss.item()  # 返回损失值以便记录

目标网络更新

这个函数将主策略网络的权重复制到目标网络。我们将使用"硬"更新,每 TARGET_UPDATE 步更新一次。

python 复制代码
def update_target_net(policy_net: nn.Module, target_net: nn.Module) -> None:
    """
    将策略网络的权重复制到目标网络。

    参数:
    - policy_net (nn.Module):主Q网络,其权重将被复制。
    - target_net (nn.Module):目标Q网络,将复制权重。

    返回:
    - None
    """
    target_net.load_state_dict(policy_net.state_dict())

运行DQN算法

设置超参数,初始化网络、优化器和回放缓存,然后运行主训练循环。

超参数设置

我们需要为自定义网格世界设置环境和超参数。这些参数将定义学习率、折扣因子、批量大小以及其他重要的DQN算法设置。

python 复制代码
# 自定义网格世界的超参数
BATCH_SIZE_CUSTOM = 128
GAMMA_CUSTOM = 0.99         # 折扣因子(鼓励向前看)
EPS_START_CUSTOM = 1.0      # 从完全探索开始
EPS_END_CUSTOM = 0.05       # 探索率最终值为 5%
EPS_DECAY_CUSTOM = 10000    # 较慢的衰减,以满足可能较大的状态空间探索需求
TAU_CUSTOM = 0.005          # 用于软更新的 Tau(备用,这里不使用)
LR_CUSTOM = 5e-4            # 学习率(可能需要调整)
MEMORY_CAPACITY_CUSTOM = 10000
TARGET_UPDATE_FREQ_CUSTOM = 20 # 目标网络更新频率
NUM_EPISODES_CUSTOM = 500      # 剧集数量
MAX_STEPS_PER_EPISODE_CUSTOM = 200 # 每个剧集的最大步数(与网格大小相关)

初始化

定义了环境和DQN之后,我们可以初始化策略和目标网络、优化器和回放缓存。

python 复制代码
# 重新实例化自定义 GridEnvironment
custom_env: GridEnvironment = GridEnvironment(rows=10, cols=10)

# 获取动作空间的大小和状态维度
n_actions_custom: int = custom_env.get_action_space_size()  # 可能的动作数量(4)
n_observations_custom: int = custom_env.get_state_dimension()  # 状态空间的维度(2)

# 初始化策略网络(主Q网络)和目标网络
policy_net_custom: DQN = DQN(n_observations_custom, n_actions_custom).to(device)  # 主Q网络
target_net_custom: DQN = DQN(n_observations_custom, n_actions_custom).to(device)  # 目标Q网络

# 将策略网络的权重复制到目标网络,并将其设置为评估模式
target_net_custom.load_state_dict(policy_net_custom.state_dict())  # 同步权重
target_net_custom.eval()  # 将目标网络设置为评估模式

# 初始化策略网络的优化器
optimizer_custom: optim.AdamW = optim.AdamW(policy_net_custom.parameters(), lr=LR_CUSTOM, amsgrad=True)

# 初始化回放缓存,指定容量
memory_custom: ReplayMemory = ReplayMemory(MEMORY_CAPACITY_CUSTOM)

# 用于绘图的列表
episode_rewards_custom = []
episode_lengths_custom = []
episode_epsilons_custom = []
episode_losses_custom = []

训练循环

现在我们已经编写好了所有代码,接下来让我们在自定义网格世界环境中训练DQN代理。

python 复制代码
print("开始在自定义网格世界中训练DQN...")

# 初始化全局计数器,用于 \(\epsilon\) 衰减
steps_done_custom = 0

# 训练循环
for i_episode in range(NUM_EPISODES_CUSTOM):
    # 重置环境并获取初始状态张量
    state = custom_env.reset()
    total_reward = 0
    current_losses = []

    for t in range(MAX_STEPS_PER_EPISODE_CUSTOM):
        # 使用 \(\epsilon\)-贪婪策略选择动作
        action_tensor, current_epsilon = select_action_custom(
            state, policy_net_custom, EPS_START_CUSTOM, EPS_END_CUSTOM, EPS_DECAY_CUSTOM, n_actions_custom
        )
        action = action_tensor.item()

        # 在环境中执行动作
        next_state_tensor, reward, done = custom_env.step(action)
        total_reward += reward

        # 为存储在回放缓存中准备张量
        reward_tensor = torch.tensor([reward], device=device, dtype=torch.float32)
        action_tensor_mem = torch.tensor([[action]], device=device, dtype=torch.long)
        done_tensor = torch.tensor([done], device=device, dtype=torch.bool)

        # 将转移存储在回放缓存中
        memory_next_state = next_state_tensor if not done else None
        memory_custom.push(state, action_tensor_mem, memory_next_state, reward_tensor, done_tensor)

        # 转移到下一个状态
        state = next_state_tensor

        # 对策略网络执行一步优化
        loss = optimize_model(
            memory_custom, policy_net_custom, target_net_custom, optimizer_custom, BATCH_SIZE_CUSTOM, GAMMA_CUSTOM
        )
        if loss is not None:
            current_losses.append(loss)

        # 如果剧集结束,则跳出循环
        if done:
            break

    # 存储剧集统计信息
    episode_rewards_custom.append(total_reward)
    episode_lengths_custom.append(t + 1)
    episode_epsilons_custom.append(current_epsilon)
    episode_losses_custom.append(np.mean(current_losses) if current_losses else 0)

    # 定期更新目标网络
    if i_episode % TARGET_UPDATE_FREQ_CUSTOM == 0:
        update_target_net(policy_net_custom, target_net_custom)

    # 每 50 个剧集打印一次进度
    if (i_episode + 1) % 50 == 0:
        avg_reward = np.mean(episode_rewards_custom[-50:])
        avg_length = np.mean(episode_lengths_custom[-50:])
        avg_loss = np.mean([l for l in episode_losses_custom[-50:] if l > 0])
        print(
            f"剧集 {i_episode+1}/{NUM_EPISODES_CUSTOM} | "
            f"最近 50 个剧集的平均奖励:{avg_reward:.2f} | "
            f"平均长度:{avg_length:.2f} | "
            f"平均损失:{avg_loss:.4f} | "
            f"\(\epsilon\):{current_epsilon:.3f}"
        )

print("自定义网格世界训练完成。")
复制代码
开始在自定义网格世界中训练DQN...
剧集 50/500 | 最近 50 个剧集的平均奖励:-13.14 | 平均长度:109.86 | 平均损失:0.0330 | \(\epsilon\):0.599
剧集 100/500 | 最近 50 个剧集的平均奖励:0.68 | 平均长度:60.18 | 平均损失:0.0290 | \(\epsilon\):0.456
剧集 150/500 | 最近 50 个剧集的平均奖励:0.20 | 平均长度:62.06 | 平均损失:0.0240 | \(\epsilon\):0.348
剧集 200/500 | 最近 50 个剧集的平均奖励:4.34 | 平均长度:40.32 | 平均损失:0.0115 | \(\epsilon\):0.293
剧集 250/500 | 最近 50 个剧集的平均奖励:5.62 | 平均长度:32.48 | 平均损失:0.0110 | \(\epsilon\):0.257
剧集 300/500 | 最近 50 个剧集的平均奖励:5.81 | 平均长度:30.50 | 平均损失:0.0068 | \(\epsilon\):0.228
剧集 350/500 | 最近 50 个剧集的平均奖励:6.96 | 平均长度:25.32 | 平均损失:0.0144 | \(\epsilon\):0.206
剧集 400/500 | 最近 50 个剧集的平均奖励:6.56 | 平均长度:25.90 | 平均损失:0.0134 | \(\epsilon\):0.187
剧集 450/500 | 最近 50 个剧集的平均奖励:6.57 | 平均长度:29.74 | 平均损失:0.0024 | \(\epsilon\):0.168
剧集 500/500 | 最近 50 个剧集的平均奖励:7.58 | 平均长度:20.84 | 平均损失:0.0010 | \(\epsilon\):0.157
自定义网格世界训练完成。

可视化学习过程

为自定义网格世界环境绘制结果图表。

python 复制代码
# 为自定义网格世界绘制结果
plt.figure(figsize=(20, 3))

# 奖励
plt.subplot(1, 3, 1)
plt.plot(episode_rewards_custom)
plt.title('DQN 自定义网格:剧集奖励')
plt.xlabel('剧集')
plt.ylabel('总奖励')
plt.grid(True)
rewards_ma_custom = np.convolve(episode_rewards_custom, np.ones(50)/50, mode='valid')
if len(rewards_ma_custom) > 0: # 避免绘制空的移动平均值
    plt.plot(np.arange(len(rewards_ma_custom)) + 49, rewards_ma_custom, label='50-剧集移动平均值', color='orange')
plt.legend()


# 长度
plt.subplot(1, 3, 2)
plt.plot(episode_lengths_custom)
plt.title('DQN 自定义网格:剧集长度')
plt.xlabel('剧集')
plt.ylabel('步数')
plt.grid(True)
lengths_ma_custom = np.convolve(episode_lengths_custom, np.ones(50)/50, mode='valid')
if len(lengths_ma_custom) > 0:
    plt.plot(np.arange(len(lengths_ma_custom)) + 49, lengths_ma_custom, label='50-剧集移动平均值', color='orange')
plt.legend()

# \(\epsilon\)
plt.subplot(1, 3, 3)
plt.plot(episode_epsilons_custom)
plt.title('DQN 自定义网格:\(\epsilon\) 衰减')
plt.xlabel('剧集')
plt.ylabel('\(\epsilon\)')
plt.grid(True)

plt.tight_layout()
plt.show()

接下来我们继续分析DQN学习曲线,并且可视化学习到的策略。

分析DQN学习曲线(自定义网格世界)

  1. 奖励曲线:奖励应该随着时间的推移而增加,可能比CartPole更波动,因为有步进成本,而且可能会暂时被困住。移动平均值应该显示出向正值学习的趋势,因为代理更频繁地到达目标(+10),同时尽量减少步数(-0.1成本)和撞墙(-1)。
  2. 剧集长度曲线:最初很高,随着代理学会更直接地到达目标,剧集长度应该会减少。平台或尖峰可能表明探索导致路径变长或被困住。收敛到从起点到目标的最小可能步数表明学习良好。
  3. (\epsilon) 衰减:显示计划的探索率随剧集的减少。

这表明DQN在自定义、手动定义的环境中学习策略,只使用基本库以及PyTorch进行神经网络组件。

可视化学习到的策略(可选)

我们可以创建一个策略网格,类似于表格方法,但现在策略是从Q网络的输出中得出的。

python 复制代码
def plot_dqn_policy_grid(policy_net: nn.Module, env: GridEnvironment, device: torch.device) -> None:
    """
    绘制从DQN得出的贪婪策略。

    参数:
    - policy_net (nn.Module):用于得出策略的训练有素的Q网络。
    - env (GridEnvironment):自定义网格环境。
    - device (torch.device):用于处理张量的设备(CPU/GPU)。

    返回:
    - None:显示策略网格的图表。
    """
    # 获取网格环境的维度
    rows: int = env.rows
    cols: int = env.cols

    # 初始化一个空网格来存储策略符号
    policy_grid: np.ndarray = np.empty((rows, cols), dtype=str)

    # 定义每个动作的符号
    action_symbols: Dict[int, str] = {0: '↑', 1: '↓', 2: '←', 3: '→'}

    # 创建图表
    fig, ax = plt.subplots(figsize=(cols * 0.6, rows * 0.6))  # 根据网格维度调整大小

    # 遍历网格中的每个单元格
    for r in range(rows):
        for c in range(cols):
            state_tuple: Tuple[int, int] = (r, c)  # 当前状态作为元组

            # 如果当前单元格是目标状态,则标记为'G'
            if state_tuple == env.goal_state:
                policy_grid[r, c] = 'G'
                ax.text(c, r, 'G', ha='center', va='center', color='green', fontsize=12, weight='bold')
            else:
                # 将状态转换为张量表示
                state_tensor: torch.Tensor = env._get_state_tensor(state_tuple)

                # 使用策略网络确定最佳动作
                with torch.no_grad():
                    # 为状态张量添加一个批次维度
                    state_tensor = state_tensor.unsqueeze(0)
                    # 获取当前状态的Q值
                    q_values: torch.Tensor = policy_net(state_tensor)
                    # 选择Q值最高的动作
                    best_action: int = q_values.max(1)[1].item()

                # 在策略网格中存储动作符号
                policy_grid[r, c] = action_symbols[best_action]
                # 将动作符号添加到图表中
                ax.text(c, r, policy_grid[r, c], ha='center', va='center', color='black', fontsize=12)

    # 设置网格可视化
    ax.matshow(np.zeros((rows, cols)), cmap='Greys', alpha=0.1)  # 背景网格
    ax.set_xticks(np.arange(-.5, cols, 1), minor=True)
    ax.set_yticks(np.arange(-.5, rows, 1), minor=True)
    ax.grid(which='minor', color='black', linestyle='-', linewidth=1)  # 次要网格线
    ax.set_xticks([])  # 移除x轴刻度
    ax.set_yticks([])  # 移除y轴刻度
    ax.set_title("DQN 学习到的策略(自定义网格)")  # 图表标题

    # 显示图表
    plt.show()


# 绘制训练有素的网络学习到的策略
print("\n绘制从DQN得出的策略:")
plot_dqn_policy_grid(policy_net_custom, custom_env, device)
复制代码
绘制从DQN得出的策略:

DQN中常见的挑战及解决方案

挑战:训练不稳定/发散

  • 解决方案
    • 调整学习率 :降低学习率 (LR)。
    • 增加目标网络更新频率 :减少目标网络的更新频率(增加 TARGET_UPDATE_FREQ),或者使用软更新 (TAU)。
    • 梯度裁剪:防止梯度爆炸。
    • 增大回放缓存 :增加 MEMORY_CAPACITY
    • 更换优化器/损失函数:尝试使用 RMSprop 或 Huber 损失。

挑战:学习速度慢

  • 解决方案
    • 调整超参数:谨慎提高学习率,调整 (\epsilon) 衰减,优化批量大小。
    • 调整网络架构:尝试不同的层数和神经元数量。
    • 使用优先经验回放:更频繁地抽样回放缓存中"重要"的转移(更复杂的扩展)。
    • 使用双DQN/双Q网络:这些扩展可以提高性能和稳定性。

挑战:Q值过高估计

  • 解决方案 :实现 双DQN,在目标计算中将动作选择和价值估计解耦。

挑战:回放缓存中的相关性

  • 解决方案:确保回放缓存足够大,并且随机抽样。考虑使用优先回放。

总结

深度Q网络(DQN)算法成功地将Q学习扩展到能够处理高维状态空间的领域,通过使用深度神经网络作为函数近似器。经验回放和目标网络等关键创新对于在将深度学习与时间差分方法结合时稳定学习过程至关重要。

正如在CartPole环境中所展示的那样,DQN能够直接从经验中学习处理连续状态空间和离散动作的有效策略。尽管基本的DQN存在一些局限性(例如,过高估计、处理连续动作),但它为许多先进的深度强化学习算法奠定了基础,如双DQN、双Q网络、Rainbow以及演员-评论家方法,这些算法解决了这些挑战并能够处理更复杂的问题。理解DQN是掌握现代强化学习技术的重要一步。

相关推荐
萧霍之4 分钟前
基于onnxruntime结合PyQt快速搭建视觉原型Demo
pytorch·python·yolo·计算机视觉
焜昱错眩..22 分钟前
代码随想录训练营第二十一天 |589.N叉数的前序遍历 590.N叉树的后序遍历
数据结构·算法
曼岛_28 分钟前
[Java实战]Spring Boot 定时任务(十五)
java·spring boot·python
Tisfy44 分钟前
LeetCode 1550.存在连续三个奇数的数组:遍历
算法·leetcode·题解·数组·遍历
wang__123001 小时前
力扣70题解
算法·leetcode·职场和发展
菜鸟破茧计划1 小时前
滑动窗口:穿越数据的时光机
java·数据结构·算法
Cloud Traveler1 小时前
Java并发编程常见问题与陷阱解析
java·开发语言·python
山海不说话1 小时前
PyGame游戏开发(含源码+演示视频+开结题报告+设计文档)
python·pygame
_Itachi__2 小时前
LeetCode 热题 100 101. 对称二叉树
算法·leetcode·职场和发展
少了一只鹅2 小时前
深入理解指针(5)
java·c语言·数据结构·算法