PyTorch强化学习实战(10)------强化学习高级组件
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%等概率产生动作0或1
2.2 智能体
智能体作为核心实体,提供了连接环境观测与待执行动作的统一接口。目前我们仅接触了无状态 DQN 智能体的简单案例:它通过神经网络 (Neural, NN)从当前观测获取动作价值,并基于贪婪策略执行动作(尽管通过ε-贪婪策略引入探索性,但核心机制未变)。
在强化学习领域,智能体的设计可能更为复杂。例如,智能体可以直接预测动作概率分布而非动作价值,这类智能体称为策略智能体 (Policy Agent)。
某些情况下,智能体需要在观测间保持状态记忆。例如,当单次观测(甚至最近 k 次观测)不足以支持动作决策时,就需要智能体具备记忆能力来保存关键信息。针对这类"部分可观测马尔可夫决策过程 (Partially Observable Markov Decision Process, POMDP) "问题,已发展出专门的 RL 子领域。
智能体的第三种变体在于连续控制问题,这类场景中的动作不再是离散值而是连续值,智能体需要根据观测预测这些连续动作。
为兼容这些变体并保持代码灵活性,我们将智能体实现为可扩展的类层次结构,顶层是抽象类 lib.agent.BaseAgent。从高层来看,智能体需要接收批次观测数据( NumPy 数组或其列表),并返回对应的批次动作。采用批处理能显著提升效率,因为在 GPU 中单次处理多个观测通常比逐条处理快得多。
抽象基类并未限定输入输出类型,这使得它极具扩展性。例如在连续控制领域,动作不再是离散索引而是浮点值。本质上,智能体可视为"观测→动作"的转换器,如何实现由智能体自行决定。虽然原则上不预设观测和动作类型,但具体实现会有所限制。我们提供了两种最常见的转换方式:DQNAgent 和 PolicyAgent。
但在实际问题中,通常需要自定义智能体,常见原因包括:
- 例如动作空间是离散与连续的混合体,或观测数据是多模态的(如文本+图像)
- 使用非标准的探索策略,例如连续控制领域常用的
Ornstein-Uhlenbeck噪声过程 POMDP环境:当智能体决策不仅依赖观测,还需内部状态时(Ornstein-Uhlenbeck探索也属此类)
这些情况均可通过继承 BaseAgent 类轻松实现。接下来,查看标准智能体:DQNAgent 和 PolicyAgent。
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) 可以将 PolicyAgent 与 ProbabilityActionSelector 结合使用。由于后者需要归一化概率,需设置 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利用率 - 可对轨迹进行预处理并以适合训练的格式输出。例如实现了包含奖励累积的子轨迹展开机制,这种预处理特别适用于
DQN和 N步DQN 算法------当我们不关心中间步骤时,可直接丢弃这些数据,从而节省内存并减少代码量 - 支持
Gymnasium的向量化环境(AsyncVectorEnv和SyncVectorEnv类)
因此,经验源类屏蔽了环境交互和轨迹处理的复杂性。但也可以根据需要继承现有类或实现自定义版本。我们提供三个核心类:
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 会返回一段智能体与环境交互的轨迹片段。实际执行了以下操作:
- 调用环境的
reset()方法获取初始状态 - 智能体根据返回的状态选择要执行的动作
- 执行
step()方法获取奖励和下一状态 - 将新状态传递给智能体以选择下一个动作
- 返回状态转移的相关信息
- 当环境返回回合结束标志时,我们会输出剩余的轨迹片段并自动重置环境重新开始
- 在遍历经验源的过程中,持续循环上述流程(从步骤
3开始)
如果智能体改变其动作生成策略(例如通过更新网络权重、降低探索率 ε ε ε 或其他方式),将立即影响我们获得的经验轨迹。
ExperienceSource 实例返回的元组长度等于或小于构造时传递的 step_count 参数。在本例中,要求生成两步子轨迹,因此元组长度将为 2 或 1 (在回合结束时)。
元组中的每个对象都是 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游戏时,这将成为极具挑战性的任务。一个细微的失误也可能会导致内存增加 10 到 100 倍,并严重拖慢训练进程。
我们可以提供多种与 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 的标准训练流程可归纳为以下步骤:
- 调用
buffer.populate(1)从环境中获取一个新样本 - 调用
batch = buffer.sample(BATCH_SIZE)从缓冲区获取一个训练批次 - 计算采样批次的损失
- 反向传播
- 重复以上步骤直到收敛
整个流程自动处理了环境重置、子轨迹管理、缓冲区维护等底层操作:
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():以 α α α 权重(0到1之间)将源网络权重混合到目标网络
第一种模式是离散动作空间问题(如 Atari 和 CartPole )中目标网络同步的标准做法。第二种模式则用于连续控制问题,这类问题需要平滑过渡两个网络的参数,因此采用混合方式,通过公式 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:每10、100或1000次训练迭代触发对应事件,有助于减少写入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)。该代码能够在 2000 至 3000 次训练迭代内收敛:
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学习