PyTorch强化学习实战(10)——强化学习高级组件

PyTorch强化学习实战(10)------强化学习高级组件

    • [0. 前言](#0. 前言)
    • [1. 强化学习高级组件](#1. 强化学习高级组件)
    • [2. 自建工具链](#2. 自建工具链)
      • [2.1 动作选择器](#2.1 动作选择器)
      • [2.2 智能体](#2.2 智能体)
      • [2.3 经验源](#2.3 经验源)
      • [2.4 经验回放缓冲区](#2.4 经验回放缓冲区)
      • [2.5 TargetNet 类](#2.5 TargetNet 类)
      • [2.6 Ignite 辅助工具](#2.6 Ignite 辅助工具)
    • [3. 使用高级组件解决 CartPole 环境](#3. 使用高级组件解决 CartPole 环境)
    • 小结
    • 系列链接

0. 前言

我们已经学习了如何实现深度Q网络 (Deep Q-Network, DQN) 模型,证明了非线性近似器完全可用于强化学习,这一概念验证极大地推动了深度Q学习乃至整个深度强化学习领域的研究热潮。在本节中,我们将重点探讨如何定义强化学习高级组件,使用更高级的模块构建代码,并聚焦于所实现方法的核心细节,避免反复实现相同的逻辑,避免重复造轮子的低效劳动。

1. 强化学习高级组件

我们所实现的DQN代码并不算复杂,训练代码约 200 行,加上 50 行的环境封装代码。初学强化学习 (Reinforcement Learning, RL)方法时,实现所有细节对理解底层原理非常有益。但随着研究的深入,会发现自己不断重复编写相似的代码逻辑。

这种重复性源于 RL 方法的高度通用性。强化学习具有极强的适应性,许多现实问题都可以纳入"环境-智能体"交互框架。由于 RL 方法对观测值和动作的具体形式几乎不做假设,为 CartPole 环境编写的代码稍加调整就能应用于 Atari 游戏(可能只需微调参数)。

重复编写相同代码不仅效率低下,每次还可能引入新 Bug,导致额外的调试和理解成本。相比之下,经过多个项目验证的精心设计的代码库,通常在性能、单元测试、可读性和文档完善度方面都有更优表现。

从计算机科学的发展历程来看,RL 的实际应用仍属新兴领域。与 Web 开发等成熟领域相比(仅 Python 生态就有 Django 全功能框架、Flask 轻量级 WSGI 应用等数百个优秀库),RL 的可用工具选择相对有限。但近年来已涌现出多个旨在简化 RL 开发的开源项目,当然我们也可以自建工具链。

2. 自建工具链

自建工具链可以提供以下功能模块:

  • 智能体 (Agent):将观测数据批次转换为待执行动作。智能体可包含可选状态值,用于在单次任务中跟踪连续动作间的关联信息。库中已为常见强化学习场景提供了多种智能体实现,若预置类无法满足需求,可通过继承 BaseAgent 基类来自定义实现。
  • 动作选择器 (ActionSelector):该轻量级逻辑组件负责从神经网络输出中选择动作,需与智能体类协同工作
  • 经验源及其子类 (ExperienceSource):Agent 实例和 Gymnasium 环境对象可以提供关于智能体的任务轨迹信息,最简形式为单次 ( a , r , s ′ ) (a, r, s') (a,r,s′) 状态转移
  • 经验缓冲池及其子类 (ExperienceSourceBuffer):具备不同特性的经验回放池,包含基础回放池及两种优先经验回放变体
  • 实用工具类:例如目标网络 (TargetNet) 和时序预处理包装器(用于 TensorBoard 训练进度追踪)
  • PyTorch Ignite 集成工具:用于接入 Ignite 框架的辅助组件
  • Gymnasium 环境包装器:包括 Atari 游戏专用包装器

以上即为自建工具链的核心架构。接下来,我们将详细介绍这些组件的实现细节。

2.1 动作选择器

动作选择器 (action selector) 是负责将神经网络输出转换为具体动作值的对象。常见应用场景包括:

  • 贪婪选择 (Greedy / argmax):Q值类方法常用,当网络预测一组动作的 Q ( s , a ) Q(s,a) Q(s,a) 值时,选择最大Q值对应的动作
  • 基于策略采样 (Policy-based):网络输出概率分布( logits 或归一化分布),需依此分布采样动作(如交叉熵方法即采用此方式)

动作选择器通常由智能体调用,一般无需自定义(但支持自定义)。具体实现类包括:

  • ArgmaxActionSelector:对输入张量的第二维应用 argmax,假设第一维是批次维度
  • ProbabilityActionSelector:从离散动作集合的概率分布中采样
  • EpsilonGreedyActionSelector:通过 epsilon 参数控制随机动作概率,内置另一个动作选择器处理非随机情况

(1) 所有类均要求输入 NumPy 数组,核心用法如下:

shell 复制代码
>>> import numpy as np 
>>> import lib
>>> q_vals = np.array([[1, 2, 3], [1, -1, 0]]) 
>>> q_vals 
array([[ 1,  2,  3], 
      [ 1, -1,  0]]) 
>>> selector = lib.actions.ArgmaxActionSelector() 
>>> selector(q_vals) 
array([2, 0])

可以看到,该选择器返回最大动作值对应的索引。

(2) 接下来是 EpsilonGreedyActionSelector,它包装另一个动作选择器,并根据 epsilon 参数的值,使用包装的选择器或采取随机动作。该选择器在训练阶段用于为智能体的动作引入随机性。如果 epsilon=0.0,则完全无随机动作:

shell 复制代码
>>> selector = lib.actions.EpsilonGreedyActionSelector(epsilon=0.0, selector=lib.actions.ArgmaxActionSelector())
>>> selector(q_vals)
array([2, 0])

(3)epsilon = 1.0,则动作完全随机:

shell 复制代码
>>> selector = lib.actions.EpsilonGreedyActionSelector(epsilon=1.0)
>>> selector(q_vals)
array([0, 1])

(4) 还可通过直接修改属性动态调整 epsilon 值:

shell 复制代码
>>> selector.epsilon
1.0
>>> selector.epsilon = 0.0
>>> selector(q_vals)
array([2, 0])

(5) ProbabilityActionSelector 的用法类似,但输入必须是归一化的概率分布(如 softmax 输出):

shell 复制代码
>>> selector = lib.actions.ProbabilityActionSelector() 
>>> for _ in range(10): 
...     acts = selector(np.array([ 
...                     [0.1, 0.8, 0.1], 
...                     [0.0, 0.0, 1.0], 
...                     [0.5, 0.5, 0.0] 
...                     ])) 
...     print(acts) 
... 
[0 2 1] 
[1 2 1] 
[1 2 1] 
[0 2 1] 
[2 2 0] 
[0 2 0] 
[1 2 1] 
[1 2 0] 
[1 2 1] 
[1 2 0]

在上述示例中,我们从三个概率分布中进行采样(对应输入矩阵的三行数据):

  • 第一行向量 [0.1, 0.8, 0.1] 表示选择索引 1 的动作概率为 80%
  • 第二个向量 [0.0, 0.0, 1.0] 必然选择索引 2 的动作
  • 第三个向量 [0.5, 0.5, 0.0] 将以 50% 等概率产生动作 01

2.2 智能体

智能体作为核心实体,提供了连接环境观测与待执行动作的统一接口。目前我们仅接触了无状态 DQN 智能体的简单案例:它通过神经网络 (Neural, NN)从当前观测获取动作价值,并基于贪婪策略执行动作(尽管通过ε-贪婪策略引入探索性,但核心机制未变)。

在强化学习领域,智能体的设计可能更为复杂。例如,智能体可以直接预测动作概率分布而非动作价值,这类智能体称为策略智能体 (Policy Agent)。

某些情况下,智能体需要在观测间保持状态记忆。例如,当单次观测(甚至最近 k 次观测)不足以支持动作决策时,就需要智能体具备记忆能力来保存关键信息。针对这类"部分可观测马尔可夫决策过程 (Partially Observable Markov Decision Process, POMDP) "问题,已发展出专门的 RL 子领域。

智能体的第三种变体在于连续控制问题,这类场景中的动作不再是离散值而是连续值,智能体需要根据观测预测这些连续动作。

为兼容这些变体并保持代码灵活性,我们将智能体实现为可扩展的类层次结构,顶层是抽象类 lib.agent.BaseAgent。从高层来看,智能体需要接收批次观测数据( NumPy 数组或其列表),并返回对应的批次动作。采用批处理能显著提升效率,因为在 GPU 中单次处理多个观测通常比逐条处理快得多。

抽象基类并未限定输入输出类型,这使得它极具扩展性。例如在连续控制领域,动作不再是离散索引而是浮点值。本质上,智能体可视为"观测→动作"的转换器,如何实现由智能体自行决定。虽然原则上不预设观测和动作类型,但具体实现会有所限制。我们提供了两种最常见的转换方式:DQNAgentPolicyAgent

但在实际问题中,通常需要自定义智能体,常见原因包括:

  • 例如动作空间是离散与连续的混合体,或观测数据是多模态的(如文本+图像)
  • 使用非标准的探索策略,例如连续控制领域常用的 Ornstein-Uhlenbeck 噪声过程
  • POMDP 环境:当智能体决策不仅依赖观测,还需内部状态时( Ornstein-Uhlenbeck 探索也属此类)

这些情况均可通过继承 BaseAgent 类轻松实现。接下来,查看标准智能体:DQNAgentPolicyAgent

2.2.1 DQNAgent

该类适用于动作空间不大的Q学习场景,涵盖Atari游戏和许多经典问题,不过这种表示方式并不通用。
DQNAgent 接收一批观测数据( NumPy 数组形式),通过神经网络获取各动作的Q值,再通过配置的 ActionSelector 将Q值转换为动作索引。我们考虑一个简单的示例。为了简化,假设网络对任何输入都输出固定值。

(1) 首先,定义一个神经网络类,用于将观察转换为动作。在本节中,仅模拟网络行为,始终输出相同结果:

python 复制代码
class DQNNet(nn.Module):
    def __init__(self, actions: int):
        super(DQNNet, self).__init__()
        self.actions = actions

    def forward(self, x):
        # we always produce diagonal tensor of shape
        # (batch_size, actions)
        return torch.eye(x.size()[0], self.actions)

(2) 定义模型类后,就可以将其作为 DQN 模型使用:

shell 复制代码
>>> net = DQNNet(actions=3) 
>>> net(torch.zeros(2, 10)) 
tensor([[1., 0., 0.], 
		[0., 1., 0.]])

(3) 首先采用简单的 argmax 策略(返回价值最大的动作),此时智能体将始终选择网络输出中对应最高值的动作:

shell 复制代码
>>> selector = lib.actions.ArgmaxActionSelector() 
>>> agent = lib.agent.DQNAgent(model=net, action_selector=selector) 
>>> agent(torch.zeros(2, 5)) 
(array([0, 1]), [None, None])

输入为包含两个观测值的批次(每个观测含五个数值),输出得到包含两个元素的元组:

  • 包含批次中需要执行动作的数组。在本节中,第一个批次样本对应动作 0,第二个样本对应动作 1
  • 包含代理内部状态的列表。用于有状态的智能体,本节为 [None, None] (因采用无状态智能体,可忽略该返回值)

(4) 下来创建带ε-贪婪探索策略的智能体,只需更换动作选择器即可:

shell 复制代码
>>> selector = lib.actions.EpsilonGreedyActionSelector(epsilon=1.0) 
>>> agent = lib.agent.DQNAgent(model=net, action_selector=selector) 
>>> agent(torch.zeros(10, 5))[0] 
array([2, 0, 0, 0, 1, 2, 1, 2, 2, 1])

(5) 由当 ε ε ε 设为 1.0 时,所有动作都将随机选择(完全忽略网络输出),但我们可以在训练过程中动态调整 ε ε ε 值:

shell 复制代码
>>> selector.epsilon = 0.5 
>>> agent(torch.zeros(10, 5))[0] 
array([0, 1, 2, 2, 0, 0, 1, 2, 0, 2]) 
>>> selector.epsilon = 0.1 
>>> agent(torch.zeros(10, 5))[0] 
array([0, 1, 2, 0, 0, 0, 0, 0, 0, 0])
2.2.2 PolicyAgent

PolicyAgent 要求神经网络输出离散动作集合的策略分布,该分布可以是未归一化的 logits (推荐使用),也可以是归一化的概率分布。实践中应优先使用 logits 以增强训练过程的数值稳定性。接下来,重构之前的示例,但网络输出概率分布。

(1) 首先定义 PolicyNet() 类:

python 复制代码
class PolicyNet(nn.Module):
    def __init__(self, actions: int):
        super(PolicyNet, self).__init__()
        self.actions = actions

    def forward(self, x):
        # Now we produce the tensor with first two actions
        # having the same logit scores
        shape = (x.size()[0], self.actions)
        res = torch.zeros(shape, dtype=torch.float32)
        res[:, 0] = 1
        res[:, 1] = 1
        return res

(2) 以上类可用于获取批量观测值的动作 logits (未归一化的对数概率):

shell 复制代码
>>> net = PolicyNet(actions=5) 
>>> net(torch.zeros(6, 10)) 
tensor([[1., 1., 0., 0., 0.], 
		[1., 1., 0., 0., 0.], 
		[1., 1., 0., 0., 0.], 
[1., 1., 0., 0., 0.], 
		[1., 1., 0., 0., 0.], 
		[1., 1., 0., 0., 0.]])

(3) 可以将 PolicyAgentProbabilityActionSelector 结合使用。由于后者需要归一化概率,需设置 PolicyAgent 对网络输出执行 softmax 转换:

shell 复制代码
>>> selector = lib.actions.ProbabilityActionSelector() 
>>> agent = lib.agent.PolicyAgent(model=net, action_selector=selector, apply_softmax=True)>>> agent(torch.zeros(6, 5))[0] 
array([2, 1, 2, 0, 2, 3])

(4) 需要注意的是:即使 logits 值为零,softmax 运算仍会产生非零概率,因此智能体仍可能选择零初始 logit 对应的动作。例如:

shell 复制代码
>>> torch.nn.functional.softmax(torch.tensor([1., 1., 0., 0., 0.])) 
tensor([0.3222, 0.3222, 0.1185, 0.1185, 0.1185])

2.3 经验源

上一节描述的智能体抽象层使我们能够以通用方式实现环境通信。这些通信以轨迹形式呈现,通过将智能体的动作应用于 Gymnasium 环境产生。

从高层次看,经验源类接收智能体实例和环境对象,并提供轨迹中的逐步数据。这些类的功能包括:

  • 支持同时与多个环境交互。当智能体批量处理观测数据时,可有效提升 GPU 利用率
  • 可对轨迹进行预处理并以适合训练的格式输出。例如实现了包含奖励累积的子轨迹展开机制,这种预处理特别适用于 DQNN步DQN 算法------当我们不关心中间步骤时,可直接丢弃这些数据,从而节省内存并减少代码量
  • 支持 Gymnasium 的向量化环境( AsyncVectorEnvSyncVectorEnv 类)

因此,经验源类屏蔽了环境交互和轨迹处理的复杂性。但也可以根据需要继承现有类或实现自定义版本。我们提供三个核心类:

  • ExperienceSource:基于智能体和环境集合,生成包含所有中间步骤的 n 步子轨迹
  • ExperienceSourceFirstLast:功能与 ExperienceSource 类似,但仅保留子轨迹的首尾两步,并正确累积期间奖励值,在 N步DQN 或优势演员-评论家 (Asynchronous Advantage Actor-Critic, A2C) 方法进行轨迹采样时,这种设计可显著节省内存空间
  • ExperienceSourceRollouts:遵循 Mnih 论文中描述的异步优势演员-评论家 (Aasynchronous Advantage Actor-Critic, A3C)轨迹展开方案,专门针对 Atari 游戏优化

所有类均在 CPU 运算效率和内存使用方面进行了深度优化。虽然这对简单问题影响不大,但在处理 Atari 游戏时(需要存储和处理海量数据),这些优化设计将显得尤为重要。

2.3.1 简单环境演示

为了演示,我们将实现一个非常简单的 Gymnasium 环境,其中包含可预测的观测状态,用以展示 ExperienceSource 类的工作原理。该环境具有以下特性,观测值为从 0 递增到 4 的整数,动作为整数类型,并且奖励值等于所采取的动作值,该环境生成的所有回合都固定为 10 步:

python 复制代码
class ToyEnv(gym.Env):
    def __init__(self):
        super(ToyEnv, self).__init__()
        self.observation_space = gym.spaces.Discrete(n=5)
        self.action_space = gym.spaces.Discrete(n=3)
        self.step_index = 0

    def reset(self):
        self.step_index = 0
        return self.step_index, {}

    def step(self, action: int):
        is_done = self.step_index == 10
        if is_done:
            return self.step_index % self.observation_space.n, 0.0, is_done, False, {}
        self.step_index += 1
        return self.step_index % self.observation_space.n, float(action), \
            self.step_index == 10, False, {}

此外,我们将使用一个无论观测值如何都始终生成固定动作的智能体:

python 复制代码
class DullAgent(lib.agent.BaseAgent):
    def __init__(self, action: int):
        self.action = action

    def __call__(self, observations: tt.List[int], state: tt.Optional[list] = None) -> tt.Tuple[tt.List[int], tt.Optional[list]]:
        return [self.action for _ in observations], state

在定义完智能体后,我们来讨论它产生的数据。

2.3.2 ExperienceSource 类

我们将讨论的第一个类是 lib.experience.ExperienceSource,它能生成指定长度的智能体轨迹片段。该实现会自动处理回合终止的情况(当环境的 step() 方法返回 is_done=True 时)并重置环境。其构造函数接受以下参数:

  • 使用的 Gymnasium 环境(也可以是环境列表)
  • 智能体实例
  • steps_count=2:要生成的子轨迹长度

该类的实例提供了标准的 Python 迭代器接口,因此可以直接通过迭代来获取子轨迹:

shell 复制代码
>>> from lib import * 
>>> env = ToyEnv() 
>>> agent = DullAgent(action=1) 
>>> exp_source = lib.experience.ExperienceSource(env=env, agent=agent, steps_count=2) 
>>> for idx, exp in zip(range(3), exp_source): 
...     print(exp) 
... 
(Experience(state=0, action=1, reward=1.0, done_trunc=False), Experience(state=1, action=1, reward=1.0, done_trunc=False)) 
...

在每次迭代时,ExperienceSource 会返回一段智能体与环境交互的轨迹片段。实际执行了以下操作:

  1. 调用环境的 reset() 方法获取初始状态
  2. 智能体根据返回的状态选择要执行的动作
  3. 执行 step() 方法获取奖励和下一状态
  4. 将新状态传递给智能体以选择下一个动作
  5. 返回状态转移的相关信息
  6. 当环境返回回合结束标志时,我们会输出剩余的轨迹片段并自动重置环境重新开始
  7. 在遍历经验源的过程中,持续循环上述流程(从步骤 3 开始)

如果智能体改变其动作生成策略(例如通过更新网络权重、降低探索率 ε ε ε 或其他方式),将立即影响我们获得的经验轨迹。
ExperienceSource 实例返回的元组长度等于或小于构造时传递的 step_count 参数。在本例中,要求生成两步子轨迹,因此元组长度将为 21 (在回合结束时)。

元组中的每个对象都是 lib.experience.Experience 类的实例,这个数据类包含以下字段:

  • state:执行动作前的观测状态
  • action:已执行的动作
  • reward:从环境获得的即时奖励
  • done_trunc:标记回合是否终止或被截断

当回合结束时,子轨迹会变短,底层环境将自动重置,因此我们无需手动处理,只需持续迭代即可:

python 复制代码
>>> for idx, exp in zip(range(15), exp_source): 
...     print(exp) 
... 
(Experience(state=0, action=1, reward=1.0, done_trunc=False), Experience(state=1, action=1,(Experience(state=4, action=1, reward=1.0, done_trunc=True),) 
...

我们可以要求 ExperienceSource 生成任意长度的子轨迹:

shell 复制代码
>>> exp_source = lib.experience.ExperienceSource(env=env, agent=agent, steps_count=4) 
>>> next(iter(exp_source))
(Experience(state=0, action=1, reward=1.0, done_trunc=False), Experience(state=1, action=1,(Experience(state=1, action=1, reward=1.0, done_trunc=False),)

也可以为其传递多个 gymnasium.Env 环境实例。这种情况下,这些环境将以轮流方式交替使用:

shell 复制代码
>>> exp_source = lib.experience.ExperienceSource(env=[ToyEnv(), ToyEnv()], agent=agent, steps_count=2)
>>> for idx, exp in zip(range(5), exp_source): 
...     print(exp) 
... 
(Experience(state=0, action=1, reward=1.0, done_trunc=False), Experience(state=1, action=1,
...

需要注意的是,当向 ExperienceSource 传递多个环境时,它们必须是相互独立的实例,而不是单一的环境实例,否则观测数据将会混乱。

2.3.3 ExperienceSourceFirstLast 类

ExperienceSource 类会以 ( s , a , r ) (s, a, r) (s,a,r) 对象列表的形式提供完整长度的子轨迹,而下一个状态s'会在后续元组中返回,但这种方式在某些情况并不方便。例如,在 DQN 训练中,我们需要一次性获取 ( s , a , r , s ′ ) (s, a, r, s') (s,a,r,s′) 元组来进行单步贝尔曼近似计算。此外,一些 DQN 扩展算法,例如 N-step DQN,可能需要将长观测序列压缩为 (初始状态, 动作, n步总奖励, 第n步后的状态) 的形式。

为了以通用的方式支持这种需求,我们实现了 ExperienceSource 的简单子类:ExperienceSourceFirstLast。其构造函数接受的参数几乎相同,但返回的数据结构不同:

shell 复制代码
>>> exp_source = lib.experience.ExperienceSourceFirstLast(env, agent, gamma=1.0, steps_count=1) 
>>> for idx, exp in zip(range(11), exp_source): 
...     print(exp) 
... 
ExperienceFirstLast(state=0, action=1, reward=1.0, last_state=1) 
...

现在,每次迭代时,它不再返回元组,而是返回一个数据类对象,包含以下字段:

  • state:决策采取动作时的初始状态
  • action:当前步骤执行的动作
  • reward:对 step_count 步骤的部分累积奖励(若 steps_count=1 则等同于即时奖励)
  • last_state:执行动作后的结果状态(若回合终止则返回 None)

这种数据结构对 DQN 训练更为方便,因为可以直接对其应用贝尔曼近似。

用更多步数来验证结果:

shell 复制代码
>>> exp_source = lib.experience.ExperienceSourceFirstLast(env, agent, gamma=1.0, steps_count=2)
>>> for idx, exp in zip(range(11), exp_source): 
...     print(exp) 
... 
ExperienceFirstLast(state=0, action=1, reward=2.0, last_state=2) 
...

每次迭代会合并两个步骤,并计算即时奖励(因此多数样本的 reward=2.0):

shell 复制代码
ExperienceFirstLast(state=3, action=1, reward=2.0, last_state=None) 
ExperienceFirstLast(state=4, action=1, reward=1.0, last_state=None)

当回合结束时,相关样本中会出现 last_state=None,同时系统会计算回合末段的奖励值。如果自行处理整个轨迹,很容易出错。

2.4 经验回放缓冲区

DQN 中,由于即时经验样本存在强相关性会导致训练不稳定,我们通常使用大型回放缓冲区来存储经验片段,再通过随机采样或优先级加权采样获取训练批次。缓冲区设有容量上限,因此当回放缓冲区达到上限时会自动淘汰旧样本。在处理大规模问题时,以下几个实现技巧至关重要:

  • 如何高效地从大型缓冲区采样
  • 如何淘汰缓冲区中的旧样本
  • 对于优先级缓冲区,如何以最优方式维护和处理优先级

当处理包含 1000 万- 1 亿个游戏画面样本的Atari游戏时,这将成为极具挑战性的任务。一个细微的失误也可能会导致内存增加 10100 倍,并严重拖慢训练进程。

我们可以提供多种与 ExperienceSource 和智能体机制无缝集成的回放缓冲区变体,通常只需让缓冲区从数据源获取新样本并采样训练批次即可。现有实现包括:

  • ExperienceReplayBuffer:固定大小的简单回放缓冲区,支持均匀采样
  • PrioReplayBufferNaive:直观但效率有限的优先级缓冲区(采样复杂度 O ( n ) O(n) O(n)),代码更易理解,适合中等规模缓冲区
  • PrioritizedReplayBuffer:采用线段树实现 O ( l o g ( n ) ) O(log(n)) O(log(n)) 复杂度的采样,代码较复杂但性能最优

回放缓冲区的典型用法如下:

shell 复制代码
>>> exp_source = lib.experience.ExperienceSourceFirstLast(env, agent, gamma=1.0, steps_count=1)
>>> buffer = lib.experience.ExperienceReplayBuffer(exp_source, buffer_size=100) 
>>> len(buffer) 
0 
>>> buffer.populate(1) 
>>> len(buffer) 
1

所有回放缓冲区提供以下接口:

  • Python 迭代器接口:可遍历缓冲区中所有样本
  • populate(N) 方法:从经验源获取 N 个样本存入缓冲区
  • sample(N) 方法:随机抽取 N 个经验样本组成批次

因此,DQN 的标准训练流程可归纳为以下步骤:

  1. 调用 buffer.populate(1) 从环境中获取一个新样本
  2. 调用 batch = buffer.sample(BATCH_SIZE) 从缓冲区获取一个训练批次
  3. 计算采样批次的损失
  4. 反向传播
  5. 重复以上步骤直到收敛

整个流程自动处理了环境重置、子轨迹管理、缓冲区维护等底层操作:

shell 复制代码
>>> for step in range(6): 
...     buffer.populate(1) 
...     if len(buffer) < 5:
...         continue 
...     batch = buffer.sample(4) 
...     print(f"Train time, {len(batch)} batch samples") 
...     for s in batch: 
...         print(s) 
... 
Train time, 4 batch samples 
ExperienceFirstLast(state=1, action=1, reward=1.0, last_state=2) 
....

2.5 TargetNet 类

引导问题 (bootstrapping problem) 即用于下一状态评估的网络会受到训练过程的影响。该问题通过将当前训练网络与下一状态Q值预测网络解耦得以解决。TargetNet 类能同步两个相同架构的神经网络。该类支持两种同步模式:

  • sync():将源网络的权重直接复制到目标网络
  • alpha_sync():以 α α α 权重( 01 之间)将源网络权重混合到目标网络

第一种模式是离散动作空间问题(如 AtariCartPole )中目标网络同步的标准做法。第二种模式则用于连续控制问题,这类问题需要平滑过渡两个网络的参数,因此采用混合方式,通过公式 w i = w i ∗ α + s i ∗ ( 1 − α ) w_i = w_i * α + s_i * (1 - α) wi=wi∗α+si∗(1−α) 计算,其中 w i w_i wi 是目标网络的第 i i i 个权重, s i s_i si 是源网络的权重。以下是 TargetNet 的代码使用示例。假设我们有一个基础网络:

python 复制代码
class DQNNet(nn.Module):
    def __init__(self):
        super(DQNNet, self).__init__()
        self.ff = nn.Linear(5, 3)

    def forward(self, x):
        return self.ff(x)     

通过以下方式创建目标网络:

shell 复制代码
>>> net = DQNNet() 
>>> net 
DQNNet( 
    (ff): Linear(in_features=5, out_features=3, bias=True) 
) 
>>> tgt_net = lib.agent.TargetNet(net)

目标网络包含两个字段:model (指向原始网络的引用)和 target_model (原始网络的深拷贝)。检查两个网络的权重,可以看到初始时它们完全相同:

shell 复制代码
>>> net.ff.weight 
Parameter containing: 
tensor([[ 0.2039,  0.1487,  0.4420, -0.0210, -0.2726], 
...
>>> tgt_net.target_model.ff.weight 
Parameter containing: 
tensor([[ 0.2039,  0.1487,  0.4420, -0.0210, -0.2726], 
...

但这两个网络彼此独立,仅保持相同架构:

shell 复制代码
>>> net.ff.weight.data += 1.0 
>>> net.ff.weight 
Parameter containing: 
tensor([[1.2039, 1.1487, 1.4420, 0.9790, 0.7274], 
...
>>> tgt_net.target_model.ff.weight 
Parameter containing: 
tensor([[ 0.2039,  0.1487,  0.4420, -0.0210, -0.2726], 
...

如需再次同步,可使用 sync() 方法:

shell 复制代码
>>> tgt_net.sync() 
>>> tgt_net.target_model.ff.weight 
Parameter containing: 
tensor([[1.2039, 1.1487, 1.4420, 0.9790, 0.7274], 
...

若需进行混合式同步,则可调用 alpha_sync()方法

shell 复制代码
>>> net.ff.weight.data += 1.0>>> net.ff.weight 
Parameter containing: 
tensor([[2.2039, 2.1487, 2.4420, 1.9790, 1.7274], 
...
>>> tgt_net.target_model.ff.weight 
Parameter containing: 
tensor([[1.2039, 1.1487, 1.4420, 0.9790, 0.7274], 
...
>>> tgt_net.alpha_sync(0.1) 
>>> tgt_net.target_model.ff.weight 
Parameter containing: 
tensor([[2.1039, 2.0487, 2.3420, 1.8790, 1.6274], 
...

2.6 Ignite 辅助工具

PyTorch Ignite 可以用于减少训练循环代码量,我们可以实现一些集成 Ignite 的小型辅助工具:

  • EndOfEpisodeHandler:附加到 ignite.Engine 上,会触发 EPISODE_COMPLETED 事件,并记录该事件对应的奖励和步数。当最近若干回合的平均奖励达到预设阈值时,它还可触发额外事件(通常用于达成目标奖励时终止训练)
  • EpisodeFPSHandler:跟踪智能体与环境之间的交互次数,计算每秒帧数 (FPS) 等性能指标,同时记录训练启动后的耗时
  • PeriodicEvents:每 101001000 次训练迭代触发对应事件,有助于减少写入 TensorBoard 的数据量

下一小节中详细说明如何使用这些类,用它们重新实现 DQN 训练,并测试若干 DQN 扩展与调优方法以提升基础 DQN 的收敛性

3. 使用高级组件解决 CartPole 环境

接下来,整合所有高级组件,尝试解决 CartPole 环境问题。

(1) 首先,创建神经网络(简单双层前馈神经网络)和目标网络,并设置 ε-贪婪动作选择器和 DQNAgent。随后创建经验源和回放缓冲区:

python 复制代码
net = Net(obs_size, HIDDEN_SIZE, n_actions) 
tgt_net = lib.agent.TargetNet(net) 
selector = lib.actions.ArgmaxActionSelector() 
selector = lib.actions.EpsilonGreedyActionSelector(epsilon=1, selector=selector) 
agent = lib.agent.DQNAgent(net, selector)exp_source = lib.experience.ExperienceSourceFirstLast(env, agent, gamma=GAMMA) 
buffer = lib.experience.ExperienceReplayBuffer(exp_source, buffer_size=REPLAY_SIZE)

仅用少量代码就完成了数据处理流程。

(2) 接下来,只需调用缓冲区的 populate() 方法填充数据,并从中采样训练批次即可:

python 复制代码
    while True:
        step += 1
        buffer.populate(1)

        for reward, steps in exp_source.pop_rewards_steps():
            episode += 1
            print(f"{step}: episode {episode} done, reward={reward:.2f}, "
                  f"epsilon={selector.epsilon:.2f}")
            solved = reward > 150
        if solved:
            print("Whee!")
            break
        if len(buffer) < 2*BATCH_SIZE:
            continue
        batch = buffer.sample(BATCH_SIZE)

在每次训练循环迭代开始时,我们让缓冲区从经验源获取一个样本,并检查是否有已完成的回合。ExperienceSource 类中的 pop_rewards_steps() 方法会返回自上次调用以来已完成回合的信息(以元组列表形式)。

(3) 随后在训练循环中,我们将一批 ExperienceFirstLast 对象转换为适合 DQN 训练的张量:

python 复制代码
        states_v, actions_v, tgt_q_v = unpack_batch(batch, tgt_net.target_model, GAMMA)
        optimizer.zero_grad()
        q_v = net(states_v)
        q_v = q_v.gather(1, actions_v.unsqueeze(-1)).squeeze(-1)
        loss_v = F.mse_loss(q_v, tgt_q_v)
        loss_v.backward()
        optimizer.step()
        selector.epsilon *= EPS_DECAY

        if step % TGT_NET_SYNC == 0:
            tgt_net.sync()

接着计算损失并执行反向传播步骤。最后,我们对动作选择器中的 ε ε ε 进行衰减(根据当前超参数设置, ε ε ε 会在第 500 次训练步骤时衰减至零),并让目标网络每 10 次训练迭代同步一次。

(4) 最后,实现unpack_batch 方法:

python 复制代码
@torch.no_grad()
def unpack_batch(batch: tt.List[ExperienceFirstLast], net: Net, gamma: float):
    states = []
    actions = []
    rewards = []
    done_masks = []
    last_states = []
    for exp in batch:
        states.append(exp.state)
        actions.append(exp.action)
        rewards.append(exp.reward)
        done_masks.append(exp.last_state is None)
        if exp.last_state is None:
            last_states.append(exp.state)
        else:
            last_states.append(exp.last_state)

    states_v = torch.as_tensor(np.stack(states))
    actions_v = torch.tensor(actions)
    rewards_v = torch.tensor(rewards)
    last_states_v = torch.as_tensor(np.stack(last_states))
    last_state_q_v = net(last_states_v)
    best_last_q_v = torch.max(last_state_q_v, dim=1)[0]
    best_last_q_v[done_masks] = 0.0
    return states_v, actions_v, best_last_q_v * gamma + rewards_v

该方法接收一批采样的 ExperienceFirstLast 对象,将它们转换成三个张量:状态 (states)、动作 (actions) 和目标Q值 (target Q values)。该代码能够在 20003000 次训练迭代内收敛:

shell 复制代码
26: episode 1 done, reward=25.00, epsilon=1.00 
52: episode 2 done, reward=26.00, epsilon=0.82 
... 
2786: episode 116 done, reward=192.00, epsilon=0.00 
Whee!

小结

本节讨论了强化学习高级组件的设计动机与需求,并深入分析了用于简化示例代码的强化学习高级组件。通过聚焦方法本质而非实现细节,后续深度学习进阶内容将更易理解。

系列链接

PyTorch强化学习实战(1)------强化学习(Reinforcement Learning,RL)详解
PyTorch强化学习实战(2)------强化学习环境库Gymnasium
PyTorch强化学习实战(3)------Gymnasium API扩展功能
PyTorch强化学习实战(4)------PyTorch基础
PyTorch强化学习实战(5)------PyTorch Ignite 事件驱动机制与实践
PyTorch强化学习实战(6)------交叉熵方法详解与实现
PyTorch强化学习实战(7)------表格学习与贝尔曼方程
PyTorch强化学习实战(8)------Q学习详解与实现
PyTorch强化学习实战(9)------深度Q学习

相关推荐
shchojj13 小时前
Advanced Technologies: Beyond Prompting - Fine-tuning
人工智能
mydeman13 小时前
智能体工程化演进:架构收敛、协议标准化与安全边界下沉
人工智能·架构·软件工程·ai编程
星辰AI13 小时前
长文本处理技术综述:突破上下文限制
人工智能·ai·语言模型
xwz小王子13 小时前
Nature 正刊:可穿戴膝关节机器人,重量仅为0.96 kg!让脊髓性肌萎缩症患儿重获站立能力
人工智能·机器人
白露与泡影13 小时前
自己用 ai 写了个链接 mysql 数据库的 mcp 工具
数据库·人工智能·mysql
掘根13 小时前
【openCV】键盘响应,像素逻辑操作,通道分离合并,抠像
人工智能·opencv·计算机视觉
一条泥憨鱼13 小时前
让AI从“死记硬背”到“开卷考试”:详解RAG技术的奥秘
人工智能·ai·语言模型·机器人·rag
EntyIU13 小时前
Python学习笔记
笔记·python·学习
霍格沃兹测试学院-小舟畅学13 小时前
高质量测试 Skill 编写手册 -- 渐进式披露
人工智能