8.1 基础DQN
首先,我们将实现与第6章中一样的DQN方法,但要使用第7章中介绍的高级库来实现。这会使代码更加紧凑,这点很重要,因为和方法逻辑不相关的细节不会使我们分心。
同时,本书的目的不是教你如何使用现有的库,而是开发你对RL方法的直觉,并在必要时从头实现一切。从我的角度来看,这是更有价值的技能,因为库有兴起衰落,而对于领域的真正理解将使你能够快速理解别人的代码并有意识地使用它。
在基础DQN的实现中,有三个模块:
Chapter08/lib/dqn_model.py
:DQN神经网络,代码和第6章中的一样,所以这里不再赘述。Chapter08/lib/common.py
:本章其他代码会用到的通用函数和声明。Chapter08/01_dqn_basic.py
:60行使用了PTAN和Ignite库的代码,实现了基础DQN方法。
8.1.1 通用库
我们从lib/common.py
的内容开始。首先,我们需要一些上一章的Pong环境的超参数。超参数保存在SimpleNamespace
对象中,该对象是Python标准库中的类,它提供了对一组键值对的简单访问。这使得我们可以轻松地为更复杂的各种Atari游戏添加一份配置,并尝试使用超参数:
SimpleNamespace
类的实例为值提供一个通用的容器。例如,对于前面的超参数,你可以这么用:
lib/common.py
中的下一个函数叫unpack_batch
,它将一批状态转移转换成适合训练的NumPy数组。每一个来自ExperienceSourceFirstLast
的状态转移的类型都是ExperienceFirstLast
,底层类型是namedtuple
,包含下列字段:
state
:来自环境的观察。action
:智能体执行的整型动作。reward
:如果使用steps_count=1
来创建ExperienceSourceFirstLast
,它就是立即奖励。对于更大的步数,它包含这么多步的折扣累积奖励。last_state
:如果状态转移对应于环境的最后一步,则这个字段是None
;否则,它包含经验链的最后一个观察。
unpack_batch
的代码如下:
注意我们是如何处理批中的最后一个状态转移的。为了避免进行这种特殊处理,对于终结状态转移,我们在last_states
中存了初始状态。为了使对Bellman更新的计算正确,我们可以在损失计算的时候用dones
数组对批进行mask操作。另一个解决方案是只对非终结状态转移的最后一个状态进行计算,但是这会使损失函数变得有点复杂。
DQN损失函数的计算由calc_loss_dqn
函数提供,代码和第6章中的几乎一样。一个小的改动是增加了torch.no_grad()
,它可以停止记录PyTorch计算图。
除了这些核心的DQN函数之外,common.py
还提供了几个和训练循环、数据生成以及TensorBoard相关的工具。第一个工具是一个实现了在训练时衰减epsilon的小类。epsilon定义了智能体采取随机动作的概率。它应该从一开始的1.0(完全随机的智能体)衰减到一个比较小的值,例如0.02或0.01。代码很简单,但几乎在所有DQN中都需要,所以用下面这个小类实现:
另外一个小函数是batch_generator
,它使用ExperienceReplayBuffer
(第7章中描述的PTAN类)作为参数,并从缓冲区中无限地生成采样得到的训练批。一开始函数会确保缓冲区中已经包含了所需数量的样本。
最后,一个名为setup_ignite
的冗长却非常有用的函数会挂载所需的Ignite处理器,以显示训练进度并将评估指标写入TensorBoard。我们来逐一查看此函数。
首先,setup_ignite
挂载了两个由PTAN提供的处理器:
EndOfEpisodeHandler
:每当游戏片段结束的时候,它会发布一个Ignite事件。当片段的平均奖励超过界限的时候,它也会发布一个事件。用它可以检测游戏是否被解决。EpisodeFPSHandler
:记录片段花费的时间以及已经和环境产生的交互数量的小类。用它可以计算每秒处理的帧数,这是一个非常重要的性能评估指标。
然后我们创建两个事件处理器,一个会在片段结束时被调用,它会在控制台显示已完成片段的相关信息。另一个在平均奖励超过超参数中定义的界限(Pong示例中是18.0)时被调用,展示游戏被解决并停止训练的消息。
函数的剩下部分和我们想记录的TensorBoard数据相关:
首先,创建一个TensorboardLogger
,它是Ignite提供的向TensorBoard写数据的一个特殊类。处理函数会返回损失值,所以我们挂载一个RunningAverage
转换(也是由Ignite提供的)来获得比较平滑的随时间推移计算的损失。
TensorboardLogger
可以记录来自Ignite的两组数据:输出(由转换函数返回的值)和评估指标(在训练过程中被计算出来并保存在engine
的状态中)。EndOfEpisodeHandler
和EpisodeFPSHandler
提供了评估指标,会在每个游戏片段结束时更新。所以,我们挂载OutputHandler
来将每次片段结束时的相关信息写入TensorBoard。
另外一组我们想记录的值是训练过程中的评估指标:损失、FPS、以及可能的用户自定义评估指标。这些值在每次训练迭代都会更新,但是我们会执行成千上万次迭代,所以每100次训练迭代才向TensorBoard保存一次数据;否则的话,数据文件会变得特别大。所有这类功能可能看起来都太复杂了,但是它提供了在训练过程中获取的统一评估指标集。实际上,Ignite不是很复杂,它提供了一个非常灵活的框架。以上就是common.py
的内容。
8.1.2 实现
现在,我们来看一下01_dqn_basic.py
,它创建所需类并开始训练。这里将省略无关代码,只关注重要的部分。完整的版本可以在GitHub仓库找到。
首先,创建环境并应用一组标准的包装器。第6章已经讨论过它们了,并且在下一章优化Pong解决方案的性能时还会讨论它们。然后,创建DQN模型和目标神经网络。
接着,创建智能体,并传入一个ε-greedy动作选择器。在训练过程中,由已经讨论过的EpsilonTracker
类降低ε值。它会降低随机选择的动作数量并将更多控制权交给NN。
接下来的两个非常重要的对象是ExperienceSource
和ExperienceReplayBuffer
。第一个对象接受智能体和环境作为参数并提供游戏片段的状态转移。这些状态转移会被保存在经验回放缓冲区。
然后,创建一个优化器并定义处理函数,每批状态转移都会调用该函数来训练模型。为了训练,我们调用common.calc_loss_dqn
函数并反向传播它的结果。
这个函数也要求EpsilonTracker
降低epsilon,并周期性地同步目标神经网络。
最后,创建Ignite的Engine
对象,用common.py
中的一个函数来配置它,并启动训练进程。
8.1.3 结果
好了,我们开始训练吧!
控制台中的每一行都是片段结束时输出的,展示了片段的奖励、步数、速度以及总训练时长。基础DQN版本通常需要100万帧才能达到18的平均奖励,所以耐心一点。训练过程中,我们可以在TensorBoard检查训练过程的动态情况,它会展示epsilon变化图、原始奖励值、平均奖励以及速度。图8.1和图8.2展示了奖励和片段步数(底部的x轴表示经过的时间,顶部的则是片段数)。
图8.1 训练过程中片段的相关信息
图8.2 训练的评估指标:速度和损失