深度强化学习实践(原书第2版)
上QQ阅读APP看书,第一时间看更新

7.2 PTAN库

该库位于GitHub(https://github.com/Shmuma/ptan)。所有后续的示例均使用PTAN 0.6版本实现,通过运行下面的命令,就可以在你的虚拟环境安装PTAN 0.6:

142-01

PATN最初的目标是简化我的RL实验,并试图在两个极端情况之间保持平衡:

  • 导入库之后,只需要编写一行带有大量参数的代码,就能训练提供的方法,比如DQN(一个非常生动的例子就是OpenAI Baselines项目)。
  • 从头开始实现一切。

第一个极端情况的方法非常不灵活。当你使用库支持的一些方法的时候,它能很好地工作。但是如果你想要实现一些酷炫的功能,你很快就会发现必须用一些hack的方法同库作者施加的限制做斗争,而不是解决想解决的问题。

第二个极端情况则给了太多的自由,需要用户自己一遍又一遍地实现回放缓冲区、轨迹处理,这些都很容易出错,且很无聊、低效。

PTAN试图平衡这两个极端情况,提供高质量的构建块来简化RL代码,同时尽量提供足够的灵活度,并保证不会限制创造力。

宏观来讲,PTAN提供了下面的实体:

  • Agent:知道如何将一批观察转换成一批需要执行的动作的类。它还可以包含可选状态,当需要在一个片段中为后续动作记录一些信息的时候可以用到。(我们会在第17章用到这个方法,在深度确定性策略梯度(Deep Deterministic Policy Gradient,DDPG)中,需要在探索的时候包含奥恩斯坦–乌伦贝克随机过程)。本库提供了好几个智能体用于最常见的一些RL场景,你也完全可以编写自己的BaseAgent子类。
  • ActionSelector:一小段与Agent协同工作的逻辑,它知道如何从网络的输出中选择动作。
  • ExperienceSource和它的变体:Agent的实例和Gym环境对象可以提供关于片段轨迹的信息。它最简单的形式就是每次一个(a, r, s')状态转移,但其功能远不止如此。
  • ExperienceSourceBuffer和它的变体:具有各种特性的回放缓冲区。包含一个简单的回放缓冲区和两个版本的带优先级的回放缓冲区。
  • 各种工具类,比如TargetNet和用于时间序列预处理的包装器(用于在TensorBoard中追踪训练进度)。
  • PyTorch Ignite帮助类可以将PTAN集成到Ignite框架中去。
  • Gym环境包装器,例如Atari游戏的包装器(从OpenAI Baselines复制而来,并做了一些调整)。

基本上就是这些了。在下面的几节中,我们将详细介绍这些内容。

7.2.1 动作选择器

用PTAN的术语来说,动作选择器是可以帮忙将网络的输出转换成具体动作值的对象。最常见的场景包括:

  • argmax:常被用在Q值方法中,也就是当用神经网络预测一组动作的Q值并需要一个Q(s, a)最大的动作时。
  • 基于策略的:网络的输出是概率分布(以logits的形式或归一化分布的形式),并且动作需要从这个分布采样。第4章已提到过这种情况,也就是讨论交叉熵方法的时候。

动作选择器会被Agent使用,基本上不需要自定义(当然你有权利自定义)。库中提供了几个具体类:

  • ArgmaxActionSelector:对传入张量的第二维执行argmax。(它假设参数是一个矩阵,并且它的第一维为批维度。)
  • ProbabilityActionSeletor:从离散动作集的概率分布中采样。
  • EpsilonGreedyActionSelector:具有epsilon参数,用来指定选择随机动作的概率。

所有的类都假设传入的是一个NumPy数组。这一节的具体例子可以在Chapter07/01_actions.py中找到。

143-01

如你所见,选择器返回价值最大的那个动作的索引。

144-01

如果epsilon0.0的话,EpsilonGreedyActionSelector的结果总是一样的,这意味着没有采取随机动作。如果将epsilon改成1,则动作的选择是全随机的:

144-02

ProbabilityActionSelector的用法是一样的,但是要求输入是归一化概率分布:

144-03

在前面的示例中,我们从三个分布中进行采样:第一个分布选择的动作的索引为1的概率为80%,第二个分布总是选择2号动作,而第三个分布中动作0和动作1被选到的概率相等。

7.2.2 智能体

智能体实体提供了统一的方式来连接从环境中得到的观察和我们希望执行的动作。到目前为止,只介绍过简单的、无状态的DQN智能体,它可以使用神经网络(NN)从当前的观察中获取动作的价值,并贪婪地使用这些值。我们已经使用ε-greedy的方式探索了环境,但是它并不能提升很多。

在RL领域,智能体还能变得更加复杂。例如,除了可以预测动作的价值之外,智能体还可以预测动作的概率分布。这样的智能体称为策略智能体,我们将会在本书的第三部分进行讨论。

另外一种情况是智能体需要在观察之间保持状态。例如,常常一个观察(甚至是最近k个观察)不足以决定动作的选取,因此要让智能体保存一些记忆来捕获必要的信息。RL有一个完整的子领域,可以通过部分可观察的马尔可夫决策过程(POMDP)来处理这类复杂问题,这在本书中并不涉及。

智能体的第三种变体在连续控制问题中很常见,这会在本书的第四部分讨论。目前只需要了解,在这种情况下,动作将不再是离散的值,而是一些连续值,而智能体需要从观察中预测这些动作。

为了能覆盖所有的变体并让代码足够灵活,在PTAN中,智能体被实现为可扩展的类层次结构,最顶部就是一个ptan.agent.BaseAgent抽象类。宏观来讲,智能体需要接受一批观察(以NumPy数组的形式)并返回一批它想执行的动作。按批处理可以加速计算,因为将好几个观察一次传给图形处理单元(GPU)处理比一个个处理要高效得多。

抽象基类并没有定义输入输出的类型,因此很容易扩展。例如,在连续域中,智能体将不再使用离散动作的索引,而是使用浮点数。

任何情况下,智能体都能视为知道如何将观察转换为动作的某样东西,并且智能体可以决定如何转换。通常我们不会假设观察或动作的类型,但是实现智能体的具体类的时候就会有限制了。PTAN提供了两个最常见的将观察转换成动作的方式:DQNAgentPolicyAgent

在实际问题中,通常需要定制智能体。原因包括:

  • NN的架构很酷炫,它的动作空间可以同时包含连续和离散值,它可以包含多种观察(例如,文本和像素)或类似的东西。
  • 你可能想要使用非标准的探索策略,例如奥恩斯坦–乌伦贝克过程(在连续控制领域非常流行的探索策略)。
  • 你有POMDP环境,智能体的动作不是完全根据观察来决定的,而会适当包含一些智能体内部的状态(奥恩斯坦–乌伦贝克探索也是如此)。

所有的这些情况都可以通过BaseAgent类的子类来实现,在本书的其余部分,将会给出几个这种重定义的示例。

现在可以研究一下库里提供的标准智能体:DQNAgentPolicyAgent。完整的示例见Chapter07/02_agents.py

DQNAgent

当动作空间不是非常大的时候,这个类可以适用于Q-learning,包括Atari游戏和很多经典的问题。这个方法不是很通用,但本书的后面将介绍如何解决这个问题。DQNAgent需要一批观察(NumPy数组)作为输入,使用网络来获得Q值,然后使用提供的ActionSelectorQ值转换成动作的索引。

我们来看一个小例子。为简单起见,假设网络始终为输入的批产生相同的输出。

146-01

一旦定义了上面的类,就可以将它用作DQN模型:

146-02

我们从简单的argmax策略开始,智能体将总是返回神经网络输出结果是1的那些动作。

146-03

输入的批会包含两个观察,分别有5个值,而输出则是智能体返回的两个对象:

  • 每个批对应要执行的动作的数组。在本示例中,第一批对应动作0,第二批对应动作1。
  • 智能体内部状态的列表。这用于有状态的智能体,本示例中则是一个None的列表。因为本例中智能体是无状态的,所以可以忽略该参数。

现在我们给智能体加上ε-greedy的探索策略。为此,只需要传入一个不同的动作选择器即可:

146-04

因为epsilon1.0,所以不管神经网络的输出是什么,所有的动作都是随机选择的。但是我们可以随时改变epsilon的值,当需要在训练的时候随着时间减小epsilon时,这个特性会很有用。

147-01

PolicyAgent

PolicyAgent需要神经网络生成离散动作集的策略分布。策略分布可以是logits(未归一化的)分布,也可以是归一化分布。实践中,最好都是用logits分布以提升训练过程的数值稳定性。

我们来重新实现上面的例子,只不过这次让神经网络生成概率:

147-02

上面的类可以用来获取一批观察(在本示例中被忽略了)的动作logits:

147-03

现在我们可以将PolicyAgentProbabilityActionSelector组合起来。由于后者需要归一化的概率,因此需要让PolicyAgent对神经网络的输出应用一个softmax。

147-04

请注意softmax操作会为0 logits生成非0的概率,所以智能体仍然可以选到下标大于1的动作。

147-05

7.2.3 经验源

上一节描述的智能体的抽象方式允许我们用通用的方式来实现和环境的交互。这些交互会以轨迹的形式发生,而轨迹是通过将智能体的动作应用于Gym环境而产生的。

宏观上来说,经验源类通过使用智能体实例和环境实例提供轨迹的每一步数据。这些类的功能包括:

  • 支持多个环境同时交互。通过让智能体一次处理一批观察来高效地利用GPU。
  • 预处理轨迹,并以对之后训练有利的方式来表示。例如,实现一个带累积奖励的子轨迹rollout的方法。当我们不关心子轨迹的各个中间步时,可以将其删除,这样的预处理对DQN和n步DQN都很方便。它节约了内存并减少了需要编写的代码量。
  • 支持来自OpenAI Universe的向量化环境。我们会在第17章Web自动化和MiniWoB环境中介绍它。

所以,经验源类充当“魔力黑盒”,向库的用户隐藏了环境交互和轨迹处理的复杂性。但是PTAN的设计理念是灵活性和可扩展性,所以,如果需要,你可以继承已有的类,也可以根据需要实现自己的版本。

系统提供了三个类:

  • ExperienceSource:使用智能体和一组环境,它可以产生带所有中间步的n步子轨迹。
  • ExperienceSourceFirstLast:和ExperienceSource一样,只不过将完整的子轨迹(带所有中间步)替换成了只带第一和最后一步的子轨迹,同时会将中间的奖励累积起来。这样可以节约很多内存,在n步DQN或advantage actor-critic(A2C)rollout中就会用到。
  • ExperienceSourceRollouts:遵循Mnih关于Atari游戏的论文中描述的asynchronous advantage actor-critic(A3C)rollout方案(参见第12章)。

所有类被设计成能高效地运行在中央处理器(CPU)和内存上,这对于玩具问题不是很重要,但是当你要解决Atari游戏问题,并需要用商用硬件在回放缓冲区保留1000万个样本时,这可能会是个大问题。

玩具环境

为了演示,我们将实现一个非常简单的Gym环境,并用一个小量级的可预测观察状态来展示经验源类怎么工作。这个环境拥有从0~4的整数形式的观察、整数形式的动作,以及和动作对应的奖励:

148-01

除了环境外,我们还会使用一个不管观察是什么都产生同样动作的智能体。

149-01

ExperienceSource类

第一个类是ptan.experience.ExperienceSource,它会产生给定长度的智能体轨迹。它会自动处理片段的结束情况(当环境的step()方法返回is_done=True时)并重置环境。

构造函数接受几个参数:

  • 要使用的Gym环境。或者也可以是环境列表。
  • 智能体实例。
  • steps_count=2:产生的轨迹长度。
  • vectorized=False:如果设成True,环境需要是一个OpenAI Universe的向量化环境。我们会在第16章详细讨论这类环境。

这个类提供了标准的Python迭代器接口,所以可以直接通过迭代它来获得子轨迹:

149-02

每次迭代,ExperienceSource都返回智能体在与环境交互时的轨迹。这可能看起来很简单,但是在示例的背后还发生了几件事情:

1)环境的reset()被调用了,用来获得初始状态。

2)智能体被要求从返回的状态中选择要执行的动作。

3)step()方法被执行,以获得奖励和下一个状态。

4)下一个状态被传给智能体以获得下一个动作。

5)从一个状态转移到下一个状态的相关信息被返回了。

6)重复该过程(从步骤3开始),一直到经验源被遍历完。

如果智能体改变了产生动作的方式(通过改变神经网络的权重、减少epsilon或一些其他手段),它会立即影响我们获取到的经验轨迹。

ExperienceSource实例返回的是元组,它的长度等于或小于传给构造函数的step_count参数。在本例中,我需要2步子轨迹,所以元组的长度不是2就是1(片段在第1步的时候就结束了)。元组中的所有对象都是ptan.experience.Experience类的实例,它是一个namedtuple,包含下列字段:

  • state:在执行动作前观察到的状态。
  • action:执行的动作。
  • reward:从env中得到的立即奖励。
  • done:片段是否结束。

如果片段结束了,子轨迹会更短,底层的环境也会被自动重置,所以我们无须关心它,只要继续迭代就好了。

150-01

我们能让ExperienceSource返回任意长度的子轨迹。

151-01

我们能传入好几个gym.Env实例。在这种情况下,它们会以round-robin的方式被调用。

151-02

ExperienceSourceFirstLast

ExperienceSource类提供了给定长度的完整的子轨迹,会返回(s, a, r)对象列表。下一个状态s'会在下一个元组返回,这种方式不是很方便。例如,在DQN的训练中,我们想要一个(s, a, r, s')的元组来一次完成1步Bellman近似。此外,还有一些DQN的扩展,例如n步DQN,可能想要将较长的观察序列压缩成(状态,动作,n步的总奖励,n步之后的状态)元组。

为了以通用的方式支持此功能,实现了ExperienceSource类的一个简单子类:Experience-SourceFirstLast。它的构造函数的参数和父类差不多,但是返回的数据不同。

151-03

现在,每次迭代它都会返回一个对象,这个对象也是一个namedtuple,包含下列字段:

  • state:用来决定动作的状态。
  • action:在这一步执行的动作。
  • rewardsteps_count步累积的奖励(在本例中,steps_count=1,所以它等同于立即奖励)。
  • last_state:在执行完动作后,得到的状态。如果片段结束了,这个值就为None

这个数据用在DQN的训练中就十分方便了,我们可以直接对它应用Bellman近似方法。

我们来尝试一下更多步的情况:

152-01

我们现在可以将每个迭代中的2步压缩在一起了,并计算立即奖励(这就是为什么大多数的样本都是reward=2.0)。更有趣的是在片段末尾的样本:

152-02

片段末尾的样本会带有last_state=None,但是我们还额外计算了片段剩余步数的累积奖励。这些小细节在你自己实现并处理所有轨迹的时候很容易出错。

7.2.4 经验回放缓冲区

在DQN中,我们很少直接处理经验样本,因为它们之间存在高度相关性,这会导致训练不稳定。我们通常会有一个很大的回放缓冲区,其中存有大量经验。然后从缓冲区采样(随机或带有优先级权重)来获得训练批。回放缓冲区通常有一个最大容量,所以当回放缓冲区添满后,旧样本会被剔除。

有好几个实现方面的技巧可以使用,当需要处理大问题的时候,这些技巧就显得尤为重要:

  • 如何从大缓冲区高效采样。
  • 如何从缓冲区中剔除旧样本。
  • 对于带优先级的缓冲区,如何以最有效的方式维护和处理优先级。

如果你想要解决Atari问题并保留100~1000万的样本(其中每个样本都是游戏中的图片),一切就都变得不简单了。一个小失误可能就会导致内存增加10~100倍,并严重降低训练的速度。

PTAN提供了好几类回放缓冲区,它们集成了ExperienceSourceAgent的机制。通常,你需要做的就是要求缓冲区从数据源那获取新样本,然后采样训练批。提供的类包含:

  • ExperienceReplayBuffer:一个简单的有预定义大小的回放缓冲区,使用均匀采样。
  • PrioReplayBufferNaive:一个简单却不高效的带优先级的回放缓冲区实现。采样的复杂度为O(n),对于大缓冲区来说会是一个大问题。这个版本相比优化过后的类来说有一个优势:它的代码更简单。
  • PrioritizedReplayBuffer:使用区间树进行采样,这使得代码变得比较晦涩,但是采样复杂度降低到了O(log(n))。

下面的代码展示了回放缓冲区是如何使用的:

153-01

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

  • Python迭代器接口,用于遍历缓冲区中的所有样本。
  • populate(N)方法,用于从经验源中获取N个样本并将其放入缓冲区。
  • sample(N)方法,用于获取包含N个经验对象的批。

所以,DQN常规的训练循环看起来像是下列步骤的无限重复:

1)调用buffer.populate(1)从环境中获取一个新样本。

2)用batch = buffer.sample(BATCH_SIZE)从缓冲区中获取批。

3)计算采样到的批的损失。

4)反向传播。

5)重复执行直到收敛。

剩下的事情(重置环境、处理子轨迹、维护缓冲区大小等)都是自动进行的。

154-01

7.2.5 TargetNet类

TargetNet是一个很小但很有用的类,利用它我们可以同步相同结构的两个NN。上一章描述了它的目的:提高训练稳定性。TargetNet支持两种同步模式:

  • sync():源神经网络的权重被复制到了目标神经网络。
  • alpha_sync():源神经网络的权重被以某alpha的比重(在0~1之间)混合到了目标神经网络。

第一种模式是在离散空间问题(例如Atari和CartPole)中同步目标神经网络的标准方法。我们在第6章就用了这个方法。后一种模式用于连续控制问题,它会在本书第四部分的几章中介绍。在此类问题中,两个神经网络之间的参数过渡应该平滑,所以使用了alpha混合策略,公式为wi=wiα+si(1–α),wi是目标神经网络的第i个参数,si是源神经网络的权重。下面用一个小示例来展示TargetNet如何在代码中使用。

假设我们有下面的神经网络:

154-02

目标神经网络可以这样创建:

155-01

目标神经网络包含两个字段:model(对源神经网络的引用)和target_model(源神经网络的深复制)。如果检查两个神经网络的权重,它们会是相同的:

155-02

它们彼此独立,但是拥有同样的结构:

155-03

如果要再次同步,可以使用sync()方法:

155-04

7.2.6 Ignite帮助类

PyTorch Ignite在第3章中简单地进行了介绍,它会在本书的其余部分大量使用以减少训练循环的代码数量。PTAN提供了几个小的帮助类来简化同Ignite的集成,它们都在ptan.ignite包中:

  • EndOfEpisodeHandler:它附加在ignite.Engine,会发布EPISODE_COMPLETED事件,并在引擎的指标中跟踪该事件的奖励和步数。它还可以在最后几个片段的平均奖励达到预定义边界时发布事件,此事件应该用在某些有目标奖励值的训练。
  • EpisodeFPSHandler:记录智能体和环境之间的交互数,并以每秒帧数的形式计算性能指标。它还可以缓存自训练开始以来经过的秒数。
  • PeriodicEvents:每10、100或1000个训练迭代发布一次相应的事件。这对于减少写入TensorBoard的数据量很有帮助。

下一章会详细说明前面的这些类要如何使用,那时将使用它们来重新实现第6章中的DQN训练,然后尝试一些DQN的扩展并做一些调整以改善基本DQN的收敛性。