在第六章中,我们实现了 DeepMind 在 2015 年发表的深度 Q 网络(DQN)模型。这篇论文对 RL 领域产生了重大影响,证明了在 RL 中使用非线性近似器的可行性。这一概念验证激发了人们对深度 Q 学习和深度 RL 的浓厚兴趣。
本章将朝着实用 RL 迈出另一歩,讨论高级 RL 库,这些库可帮助您从更高级的模块构建代码,使您能够专注于所实现方法的细节,避免重复编写相同的逻辑。本章大部分内容将介绍 PyTorch AgentNet(PTAN)库,本书后续章节将使用该库来避免代码重复,因此会对其进行详细介绍。
我们将涵盖以下内容:
- 为什么使用高级库,而不是从头开始重新实现一切
- PTAN 库,包括最重要的部分,并通过代码示例进行说明
- 使用 PTAN 库在 CartPole 上实现 DQN
- 其他您可能考虑使用的 RL 库
1. 为什么需要 RL 库?
我们在第六章中实现的基本 DQN 并不十分冗长和复杂 —— 训练代码约 200 行,环境包装器约 50 行。当您熟悉 RL 方法时,自己实现一切对于理解实际工作原理非常有用。然而,随着您对该领域的深入,您会频繁发现自己在一遍又一遍地编写相同的代码。
这种重复性源于 RL 方法的通用性。正如我们在第一章中所讨论的,RL 非常灵活,许多实际问题都可以归入环境 - 代理交互方案。RL 方法对观察和动作的具体细节没有太多假设,因此为 CartPole 环境编写的代码只需稍作调整,即可应用于 Atari 游戏。
反复编写相同的代码效率低下,因为每次都可能引入错误,需要花费时间进行调试和理解。此外,经过精心设计且在多个项目中使用的代码,通常在性能、单元测试、可读性和文档方面具有更高的质量。
RL 的实际应用在计算机科学领域尚属年轻,因此与其他更成熟的领域相比,您的选择可能并不丰富。例如,在 Web 开发中,即使仅使用 Python,您也有数百个优秀的库可供选择:用于重量级全功能网站的 Django,用于轻量级 WSGI 应用程序的 Flask,等等,不一而足。
RL 不如 Web 框架成熟,但您仍然可以从多个项目中进行选择,这些项目试图简化 RL 从业者的工作。此外,您始终可以像我几年前那样,编写自己的工具集。我创建的工具集是一个名为 PTAN 的库,如前所述,本书后续章节将使用它来简化示例代码。
2. PTAN 库
该库可在 GitHub 上获取:https://github.com/Shmuma/ptan。本书后续示例均使用 PTAN 的 0.8 版本,可在虚拟环境中通过以下命令安装:
$ pip install ptan==0.8
PTAN 的初衷是简化我的 RL 实验,它试图在两个极端之间取得平衡:
- 导入库后,仅需几行代码并设置大量参数即可训练提供的方法(如 OpenAI Baselines 和 Stable Baselines3 项目)。这种方法非常灵活,当您按库的设计意图使用它时效果很好。但是,如果您想进行一些特殊操作,很快就会发现自己在修改库代码,并与库的限制作斗争,而不是解决您要处理的问题。
- 从头开始实现所有方法的逻辑。这种极端方法给予您极大的自由度,但需要反复实现重放缓冲区和轨迹处理,容易出错、繁琐且效率低下。
PTAN 试图在这两个极端之间取得平衡,提供高质量的构建模块来简化 RL 代码,同时保持灵活性,不限制您的创造力。
从高层次来看,PTAN 提供以下实体:
- Agent:一个知道如何将一批观察转换为一批要执行的动作的类。它可以包含一个可选状态,以防您需要在一集中的连续动作之间跟踪某些信息(我们将在第十五章的深度确定性策略梯度(DDPG)方法中使用这种方法,该方法包括用于探索的 Ornstein-Uhlenbeck 随机过程)。该库提供了几种适用于最常见 RL 场景的代理,但如果预定义的类不能满足需求,您始终可以编写 BaseAgent 的子类。
- ActionSelector:一种小型逻辑组件,知道如何根据网络输出选择动作。它与 Agent 类协同工作。
- ExperienceSource 及其子类:Agent 实例和 Gym 环境对象可提供有关代理在各集中的轨迹信息。其最简单的形式是一次提供一个(a, r, s’)转换,但它的功能不止于此。
- ExperienceSourceBuffer 及其子类:具有各种特性的重放缓冲区,包括简单重放缓冲区和两种优先重放缓冲区版本。
- 各种实用类:例如 TargetNet 和用于时间序列预处理的包装器(用于在 TensorBoard 中跟踪训练进度)。
- PyTorch Ignite 助手:用于将 PTAN 集成到 Ignite 框架中。
- Gym 环境包装器:例如,与第六章中描述的类似的 Atari 游戏包装器。
2.1 动作选择器
在 PTAN 术语中,动作选择器是一个帮助将网络输出转换为具体动作值的对象。最常见的情况包括:
- 贪婪(或 argmax):常用于 Q 值方法,当网络预测动作的 Q 值时,所需的动作是具有最大 Q (s,a) 的动作。
- 基于策略:网络输出动作的概率分布(以 logits 或归一化分布的形式),需要从该分布中采样动作。您已经在第四章讨论交叉熵方法时见过这种情况。
动作选择器由 Agent 使用,很少需要自定义(但您可以选择这样做)。库中提供的具体类包括:
- ArgmaxActionSelector:在传递的张量的第二轴上应用 argmax。它假定矩阵的第一轴为批次维度。
- ProbabilityActionSelector:从离散动作集的概率分布中采样。
- EpsilonGreedyActionSelector:具有 epsilon 参数,该参数指定采取随机动作的概率。它还包含另一个 ActionSelector 实例,用于在不采样随机动作时使用。
以下是来自本节完整示例的示例(Chapter07/01_actions.py),展示了这些类的使用方式:
>>> import numpy as np
>>> import ptan
>>> q_vals = np.array([[1, 2, 3], [1, -1, 0]])
>>> q_vals
array([[ 1, 2, 3],
[ 1, -1, 0]])
>>> selector = ptan.actions.ArgmaxActionSelector()
>>> selector(q_vals)
array([2, 0]) # 第一行中最大值 3 的索引为 2,第二行中最大值 1 的索引为 0
如上所示,选择器返回具有最大值的动作索引。
下一个动作选择器是 EpsilonGreedyActionSelector,它 “包装” 另一个动作选择器,并根据 epsilon 参数决定是使用被包装的动作选择器还是采取随机动作。此动作选择器用于训练期间向代理的动作中引入随机性。如果 epsilon 为 0.0,则不采取随机动作:
>>> selector = ptan.actions.EpsilonGreedyActionSelector(epsilon=0.0, selector=ptan.actions.ArgmaxActionSelector())
>>> selector(q_vals)
array([2, 0]) # 输出: array([2, 0])(因为epsilon为0,所以总是选择最大值)
如果将 epsilon 设置为 1,动作将是随机的:
selector = ptan.actions.EpsilonGreedyActionSelector(epsilon=1.0)
selector(q_vals) # 输出类似: array([0, 1])(每次运行结果可能不同)
您还可以通过分配动作选择器的属性来动态更改 epsilon 值:
>>> selector.epsilon
1.0
>>> selector.epsilon = 0.0
>>> selector(q_vals)
array([2, 0])
使用 ProbabilityActionSelector 的方式类似,但输入需要是归一化的概率分布:
>>> selector = ptan.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 代理,它使用神经网络(NN)从当前观察中获取动作值,并根据这些值采取贪婪行为。我们使用 epsilon - 贪婪行为来探索环境,但这并未改变基本情况。
在 RL 领域,情况可能更为复杂。例如,代理可能不预测动作值,而是预测动作的概率分布,这种代理称为策略代理,我们将在第三部分讨论这类方法。
在某些情况下,代理需要在观察之间保持状态。例如,仅一个观察(甚至 k 个最近的观察)可能不足以做出动作决策,我们希望代理拥有一些内存来捕获必要的信息。有一整个 RL 子领域致力于使用部分可观察马尔可夫决策过程(POMDP)形式主义来解决这一复杂问题,我们在第六章简要提及了这一点,但本书不会深入探讨。
第三类代理在连续控制问题中非常常见,我们将在第四部分讨论这些问题。目前,您只需知道,在这种情况下,动作不再是离散的,而是连续值,代理需要根据观察预测这些值。
为了捕获所有这些变体并使代码具有灵活性,PTAN 中的代理被实现为一个可扩展的类层次结构,顶部是 ptan.agent.BaseAgent 抽象类。从高层次来看,代理需要接受一批观察(以 NumPy 数组或观察列表的形式),并返回一批要执行的动作。使用批次是为了提高处理效率,因为在 GPU 上一次性处理多个观察通常比单独处理每个观察快得多。
抽象基类未定义输入和输出的类型,这使其非常灵活且易于扩展。例如,在连续域中,我们的动作不再是离散动作的索引,而是浮点值。尽管如此,代理仍可视为知道如何将观察转换为动作的实体,具体实现方式由代理决定。一般来说,对观察和动作类型没有假设,但具体的代理实现会有一定的限制。PTAN 提供了两种最常见的将观察转换为动作的代理:DQNAgent 和 PolicyAgent,我们将在后续章节中探讨。
然而,在实际问题中,通常需要自定义代理。以下是一些需要自定义代理的原因:
- 网络架构独特 —— 动作空间是连续和离散的混合,或者具有多模态观察(例如,文本和像素)等。
- 您希望使用非标准的探索策略,例如 Ornstein-Uhlenbeck 过程(连续控制领域中非常流行的探索策略)。
- 您具有 POMDP 环境,代理的决策不完全由观察决定,而是由代理的某些内部状态决定(这也是 Ornstein-Uhlenbeck 探索的情况)。
所有这些情况都可以通过子类化 BaseAgent 类轻松支持,本书后续章节将给出几个此类重新定义的示例。
现在,让我们查看库中提供的标准代理:DQNAgent 和 PolicyAgent。完整示例位于 Chapter07/02_agents.py 中。
2.2.1 DQNAgent
此类适用于动作空间不大的 Q 学习场景,涵盖 Atari 游戏和许多经典问题。这种表示并非通用,本书后续章节将介绍处理其他情况的方法。DQNAgent 接受一批观察(以 NumPy 数组形式),将其输入网络以获取 Q 值,然后使用提供的 ActionSelector 将 Q 值转换为动作索引。
为简单起见,我们的网络始终为输入批次生成相同的输出。首先,定义一个 NN 类,该类本应将观察转换为动作,但在我们的示例中,它根本不使用 NN,始终生成相同的输出:
class DQNNet(nn.Module):
def __init__(self, actions: int):
super(DQNNet, self).__init__() # 调用父类(nn.Module)的构造函数,以确保正确初始化
self.actions = actions
def forward(self, x):
# 我们始终生成形状为(batch_size, actions)的对角张量
# x.size()[0] 获取输入 x 的第一个维度的大小,即批次大小
return torch.eye(x.size()[0], self.actions)
定义模型类后,我们可以将其用作 DQN 模型:
>>> net = DQNNet(actions=3)
>>> net(torch.zeros(2, 10)) # 2 行 和 10 列
tensor([[1., 0., 0.],
[0., 1., 0.]])
我们从简单的 argmax 策略开始(返回具有最大值的动作),因此代理将始终根据网络输出返回动作:
selector = ptan.actions.ArgmaxActionSelector()
agent = ptan.agent.DQNAgent(model=net, action_selector=selector)
agent(torch.zeros(2, 5)) # 输出: (array([0, 1]), [None, None])
在输入中,给出了一批包含两个观测值的样本,每个观测值有五个值。在输出中,智能体返回了一个包含两个对象的元组:
- 一个数组,包含针对这批样本要执行的动作。在我们的例子中,第一批样本的动作是0,第二批样本的动作是1。
- 一个列表,包含智能体的内部状态。这用于有状态的智能体,在我们的例子中是一个包含None的列表。由于我们的智能体是无状态的,你可以忽略它。
现在,让我们使用 epsilon - 贪婪探索策略创建代理。为此,只需传递不同的动作选择器:
selector = ptan.actions.EpsilonGreedyActionSelector(epsilon=1.0)
agent = ptan.agent.DQNAgent(model=net, action_selector=selector)
agent(torch.zeros(10, 5))[0] # 输出类似: array([2, 0, 0, 0, 1, 2, 1, 2, 2, 1])(因为epsilon=1,所以动作完全随机)
由于 epsilon 为 1.0,所有动作均为随机,无论网络输出如何。但我们可以动态更改 epsilon 值,这在训练期间逐渐降低 epsilon 非常方便:
selector.epsilon = 0.5
agent(torch.zeros(10, 5))[0] # 输出: 部分随机,部分为贪婪选择
selector.epsilon = 0.1
agent(torch.zeros(10, 5))[0] # 输出: 大部分为贪婪选择,偶尔随机
扩展:
# agent(torch.zeros(6, 5)) 返回的元组
(
array([2, 0, 1, 1, 0, 2]), # 动作数组(索引0)
[None, None, None, None, None, None] # 状态列表(索引1)
)
# 通过 [0] 提取后:
array([2, 0, 1, 1, 0, 2])
2.2.2 PolicyAgent
PolicyAgent 期望网络生成离散动作集的策略分布。策略分布可以是 logits(未归一化)或归一化分布。实际上,为了提高训练的数值稳定性,应始终使用 logits。
让我们重新实现前面的示例,但现在网络将生成概率。首先定义以下类:
class PolicyNet(nn.Module):
def __init__(self, actions: int):
super(PolicyNet, self).__init__()
self.actions = actions
def forward(self, x):
# 现在我们生成前两个动作具有相同logit分数的张量
shape = (x.size()[0], self.actions)
res = torch.zeros(shape, dtype=torch.float32) # 然后创建一个全零的张量res,数据类型为torch.float32,形状为shape。
# 每一行的第 0 个和第 1 个元素设置为 1
res[:, 0] = 1
res[:, 1] = 1
return res
上述类可用于获取一批观察的动作 logits:
>>> 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.]])0)
现在,我们可以将 PolicyAgent 与 ProbabilityActionSelector 结合使用。由于后者期望归一化概率,我们需要要求 PolicyAgent 对网络输出应用 softmax:
selector = ptan.actions.ProbabilityActionSelector()
agent = ptan.agent.PolicyAgent(model=net, action_selector=selector, apply_softmax=True)
agent(torch.zeros(6, 5))[0] # 输出类似: array([2, 1, 2, 0, 2, 3])(根据softmax后的概率采样)
请注意,softmax 操作会为零 logits 生成非零概率,因此我们的代理仍可能选择 logit 为零的动作:
torch.nn.functional.softmax(torch.tensor([1., 1., 0., 0., 0.])) # 输出: tensor([0.3222, 0.3222, 0.1185, 0.1185, 0.1185])
应用 softmax 计算概率分布:
P(0) = P(1) = e^1 / (e^1 + e^1 + e^0 + e^0 + e^0) ≈ 0.3222
P(2) = P(3) = P(4) = e^0 / (e^1 + e^1 + e^0 + e^0 + e^0) ≈ 0.1185
2.3 经验源
前一节中描述的抽象代理使我们能够以通用方式实现与环境的通信。这些通信以轨迹的形式产生,通过将代理的动作应用于 Gym 环境而生成。
从高层次来看,经验源类接受代理实例和环境,并为您提供轨迹的逐步数据。这些类的功能包括:
- 支持同时与多个环境通信:这允许高效利用 GPU,因为可以一次性处理一批观察。
- 轨迹可以预处理并以方便训练的形式呈现:例如,存在 n 步展开轨迹并累积奖励的实现。这种预处理对于 DQN 和 n 步 DQN 非常方便,因为我们不关心子轨迹中的单个中间步骤,因此可以将其丢弃,从而节省内存并减少所需编写的代码量。
- 支持 Gymnasium 的向量化环境(AsyncVectorEnv 和 SyncVectorEnv 类):我们将在第十七章中讨论这一点。
因此,经验源类充当 “魔法黑盒”,向库用户隐藏环境交互和轨迹处理的复杂性。但 PTAN 的总体理念是保持灵活性和可扩展性,因此如果需要,您可以对现有类进行子类化或实现自己的版本。
系统提供了三个类:
- ExperienceSource:使用代理和一组环境生成指定长度的子轨迹。该实现会自动处理集中的结束情况(当环境的 step () 方法返回 is_done=True 时会重置环境)
- ExperienceSourceFirstLast:它与ExperienceSource 相同,但它只保留子轨迹的第一个和最后一个步骤,并在两者之间累积奖励。这在 n 步 DQN 或优势演员 - 评论家(A2C)展开中可以节省大量内存。
- ExperienceSourceRollouts:这遵循了Mnih关于雅达利游戏的论文中描述的异步优势演员评论家(A3C)滚动方案(我们将在第12章讨论这个主题)。
所有类的编写在中央处理器(CPU)和内存方面都力求高效,这对于简单(toy)问题来说并非至关重要,但在下一章我们处理需要存储和处理大量数据的Atari 游戏时,这就变得至关重要了。
2.3.1 玩具环境
为了演示,我们将实现一个非常简单的 Gym 环境,该环境具有可预测的小观察状态,以展示 ExperienceSource 类的工作方式。此环境具有整数观察(从 0 到 4 递增)、整数动作,且奖励等于所执行的动作。所有由环境生成的剧集始终有 10 个步骤:
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, {}
除了这个环境,我们还将使用一个无论观察如何,始终生成固定动作的代理:
class DullAgent(ptan.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
这两个类定义在 Chapter07/lib.py 模块中。现在我们已经定义了代理,接下来讨论它生成的数据。
2.3.2 ExperienceSource 类
我们首先讨论的类是 ptan.experience.ExperienceSource,它从代理和环境生成指定长度的子轨迹块。该实现会自动处理剧集结束的情况(当环境的 step () 方法返回 is_done=True 时重置环境)。构造函数接受几个参数:
- 要使用的 Gym 环境(也可以是环境列表)。
- 代理实例。
- steps_count=2:要生成的子轨迹长度。
该类实例提供标准的 Python 迭代器接口,因此您可以直接对其进行迭代以获取子轨迹:
from lib import *
env = ToyEnv()
agent = DullAgent(action=1)
exp_source = ptan.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))
(Experience(state=1, action=1, reward=1.0, done_trunc=False), Experience(state=2, action=1, reward=1.0, done_trunc=False))
(Experience(state=2, action=1, reward=1.0, done_trunc=False), Experience(state=3, action=1, rewar
在每次迭代中,ExperienceSource 返回代理在环境中通信的一段轨迹。这看起来很简单,但在我们的示例背后发生了以下操作:
- 调用环境的 reset () 以获取初始状态。
- 要求代理根据返回的状态选择要执行的动作。
- 执行 step () 以获取奖励和下一个状态。
- 将从一个状态到下一个状态的转换信息返回。
- 如果环境返回剧集结束标志,我们发出剩余的轨迹并重置环境以重新开始。
- 迭代继续(从步骤 3 开始)。
如果代理改变其生成动作的方式(例如,通过更新网络权重、降低 epsilon 或其他方式),它将立即影响我们获得的经验轨迹。
ExperienceSource 实例返回的元组长度等于构造函数中传递的 step_count 参数(或在剧集结束时更短)。每个元组中的对象是 ptan.experience.Experience 类的实例,该类是一个数据类,包含以下字段:
- state:执行动作前观察到的状态。
- action:执行的动作。
- reward:从环境获得的即时奖励。
- done_trunc:剧集是否结束或被截断。
如果情节结束,子轨迹会更短,底层环境将自动重置,所以我们无需为此操心,只需继续迭代:
>>> 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, reward=1.0, done_trunc=False))
.......
(Experience(state=3, action=1, reward=1.0, done_trunc=False),
Experience(state=4, action=1, reward=1.0, done_trunc=True))
(Experience(state=4, action=1, reward=1.0, done_trunc=True),)
(Experience(state=0, action=1, reward=1.0, done_trunc=False),
Experience(state=1, action=1, reward=1.0, done_trunc=False))
(Experience(state=1, action=1, reward=1.0, done_trunc=False),
Experience(state=2, action=1, reward=1.0, done_trunc=False))
我们可以向经验源请求任意长度的子轨迹:
>>> exp_source = ptan.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, reward=1.0, done_trunc=False),
Experience(state=2, action=1, reward=1.0, done_trunc=False),
Experience(state=3, action=1, reward=1.0, done_trunc=False))
我们可以向它传入几个`gym.Env`实例。在这种情况下,它们将以循环方式使用:
>>> exp_source = ptan.experience.ExperienceSource(env=[ToyEnv(), ToyEnv()], agent=agent, steps_count=4)
>>> 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, reward=1.0, done_trunc=False),
Experience(state=2, action=1, reward=1.0, done_trunc=False),
Experience(state=3, action=1, reward=1.0, done_trunc=False))
(Experience(state=0, action=1, reward=1.0, done_trunc=False),
Experience(state=1, action=1, reward=1.0, done_trunc=False),
Experience(state=2, action=1, reward=1.0, done_trunc=False),
Experience(state=3, action=1, reward=1.0, done_trunc=False))
(Experience(state=1, action=1, reward=1.0, done_trunc=False), Experience(state=2, action=1, reward=1.0, done_trunc=False), Experience(state=3, action=1, reward=1.0, done_trunc=False), Experience(state=4, action=1, reward=1.0, done_trunc=False))
(Experience(state=1, action=1, reward=1.0, done_trunc=False), Experience(state=2, action=1, reward=1.0, done_trunc=False), Experience(state=3, action=1, reward=1.0, done_trunc=False), Experience(state=4, action=1, reward=1.0, done_trunc=False))
(Experience(state=2, action=1, reward=1.0, done_trunc=False), Experience(state=3, action=1, reward=1.0, done_trunc=False), Experience(state=4, action=1, reward=1.0, done_trunc=False), Experience(state=0, action=1, reward=1.0, done_trunc=False))
请注意,当你将多个环境传递给ExperienceSource时,它们必须是独立的实例,而不是单个环境实例,否则你的观察结果会变得混乱。
2.3.3 ExperienceSourceFirstLast类
ExperienceSource类以(s, a, r)对象列表的形式为我们提供给定长度的完整子轨迹。下一个状态s′会在接下来的元组中返回,这并不总是很方便。例如,在深度Q网络(DQN)训练中,我们希望一次性获得元组(s, a, r, s′),以便在训练过程中进行单步贝尔曼近似。此外,深度Q网络的一些扩展,如n步深度Q网络,可能希望将更长的观测序列合并为(初始状态、动作、n步总奖励、n步后的状态)。
为了以通用方式支持这一点,PTAN 实现了 ExperienceSourceFirstLast 的子类。它接受与 ExperienceSource 几乎相同的构造函数参数,但返回不同的数据:
exp_source = ptan.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)
ExperienceFirstLast(state=1, action=1, reward=1.0, last_state=2)
ExperienceFirstLast(state=2, action=1, reward=1.0, last_state=3)
ExperienceFirstLast(state=3, action=1, reward=1.0, last_state=4)
ExperienceFirstLast(state=4, action=1, reward=1.0, last_state=0)
ExperienceFirstLast(state=0, action=1, reward=1.0, last_state=1)
...
ExperienceFirstLast(state=4, action=1, reward=1.0, last_state=None) # 剧集结束,last_state为None
ExperienceFirstLast(state=0, action=1, reward=1.0, last_state=1)
现在,每次迭代返回一个对象,而不是元组,该对象是另一个数据类,包含以下字段:
- state:用于决定动作的状态。
- action:在该状态下执行的动作。
- reward:对 steps_count 步的累积奖励(在我们的示例中,steps_count=1,因此等于即时奖励)。
- last_state:执行动作后的状态。如果剧集结束,该字段为 None。
此数据对于 DQN 训练更为方便,因为我们可以直接对其应用贝尔曼近似。
让我们使用更大的步数来检查结果:
exp_source = ptan.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) # 0→1→2,奖励1+1=2
ExperienceFirstLast(state=1, action=1, reward=2.0, last_state=3)
ExperienceFirstLast(state=2, action=1, reward=2.0, last_state=4)
ExperienceFirstLast(state=3, action=1, reward=2.0, last_state=0) # 3→4→0(因step_index=10,剧集结束,last_state=0但done_trunc=True)
ExperienceFirstLast(state=4, action=1, reward=1.0, last_state=None) # 最后一步,仅一步,奖励1
在剧集结束时,我们会看到 last_state 为 None,并且奖励仅为最后一步的奖励(因为无法累积后续步骤的奖励)。这些微小细节在手动实现轨迹处理时很容易出错,但ExperienceSourceFirstLast 会为我们处理好。
2.4 经验重放缓冲区
在 DQN 中,我们很少处理即时经验样本,因为它们高度相关,可能导致训练不稳定。通常,我们使用大型重放缓冲区,其中填充了经验片段,然后按优先级(随机或按优先级)对缓冲区进行采样以获取训练批次。重放缓冲区通常有最大容量,因此当缓冲区达到限制时,旧样本会被移出。
这里有几个实现技巧,当处理大型问题时变得极为重要:
- 如何高效地从大型缓冲区中采样。
- 如何将旧样本从缓冲区中移出。
- 在优先缓冲区的情况下,如何维护和高效处理优先级。
当处理包含数百万张图像的 Atari 游戏缓冲区时,这些问题变得非常重要。一个小错误可能导致内存增加 10-100 倍,或训练速度大幅下降。
PTAN 提供了几种重放缓冲区变体,它们与 ExperienceSource 和 Agent 机制简单集成。通常,您需要做的是要求缓冲区从源获取新样本并采样训练批次。提供的类包括:
- ExperienceReplayBuffer:具有预定义大小的简单重放缓冲区,采用均匀采样。
- PrioReplayBufferNaive:简单但效率不高的优先重放缓冲区实现,采样复杂度为 O (n),在大型缓冲区中可能成为问题。此版本的优势是代码简单,对于中等大小的缓冲区,性能仍然可以接受,因此我们将在某些示例中使用它。
- PrioritizedReplayBuffer:使用段树进行采样,使代码复杂,但采样复杂度为 O (log n)。
以下是如何使用重放缓冲区的示例:
exp_source = ptan.experience.ExperienceSourceFirstLast(env, agent, gamma=1.0, steps_count=1)
buffer = ptan.experience.ExperienceReplayBuffer(exp_source, buffer_size=100)
len(buffer) # 0
buffer.populate(1) # 从经验源获取1个样本
len(buffer) # 1
所有重放缓冲区都提供以下接口:
- Python 迭代器接口,用于遍历缓冲区中的所有样本。
- populate (N) 方法:从经验源获取 N 个样本并放入缓冲区。
- sample (N) 方法:获取 N 个经验对象的批次。
因此,DQN 的正常训练循环是无限重复以下步骤:
- 调用 buffer.populate (1) 从环境获取新样本。
- 调用 batch = buffer.sample (BATCH_SIZE) 从缓冲区获取批次。
- 计算批次的损失。
- 反向传播。
- 重复直到收敛(希望如此)。
所有其他操作(如重置环境、处理子轨迹、维护缓冲区大小等)均自动完成:
>>> 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)
ExperienceFirstLast(state=2, action=1, reward=1.0, last_state=3)
ExperienceFirstLast(state=2, action=1, reward=1.0, last_state=3)
ExperienceFirstLast(state=0, action=1, reward=1.0, last_state=1)
Train time, 4 batch samples
ExperienceFirstLast(state=0, action=1, reward=1.0, last_state=1)
ExperienceFirstLast(state=4, action=1, reward=1.0, last_state=0)
ExperienceFirstLast(state=3, action=1, reward=1.0, last_state=4)
ExperienceFirstLast(state=3, action=1, reward=1.0, last_state=4)
2.5 TargetNet 类
我们在第六章讨论了引导问题,即用于评估下一状态的网络会受到训练过程的影响。解决此问题的方法是将当前训练的网络与用于预测下一状态 Q 值的网络分离,这通过 TargetNet 类实现。
TargetNet 是一个小型但有用的类,允许我们同步两个相同架构的神经网络。该类支持两种同步模式:
- sync ():将源网络的权重复制到目标网络。
- alpha_sync ():将源网络的权重以一定的 alpha 权重(0 到 1 之间)混合到目标网络中。
第一种模式是离散动作空间问题(如 Atari 和 CartPole)中执行目标网络同步的标准方式,如第六章所述。后一种模式用于连续控制问题,我们将在第四部分讨论,其中两个网络参数之间的过渡应平滑,因此使用公式 wi = wi*α + si(1 −α) 进行权重混合,其中 wi 是目标网络的第 i 个参数,si 是源网络的权重。
以下是在代码中使用 TargetNet 的小示例。假设我们有以下网络:
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)
(先到这,本章是实现的基本 DQN的代码优化)