6.4 DQN应用于Pong游戏
在讨论代码之前,需要进行一些介绍。我们的例子变得越来越具有挑战性、越来越复杂了,这不足为奇,因为要解决的问题的复杂性也在增加。这些示例会尽可能简单明了,但有些代码很可能一开始就很难理解。
还需要注意性能。前面针对FrozenLake或CartPole的示例中性能没有那么重要,因为观察值很少,NN参数很小,在训练循环中节省额外的时间并不那么重要。但是,从现在开始,情况不再如此了。Atari环境中的一个观察有10万个值,这些值必须重新缩放,转换为浮点数并存储在回放缓冲区中。复制此数据可能会降低训练速度,即使是使用最快的图形处理单元(GPU),需要的也不再是几秒或几分钟,而是数小时。
NN训练循环也可能成为瓶颈。当然,RL模型不像最新的ImageNet模型那样庞大,但即使是2015年的DQN模型也具有超过150万个参数,这对GPU来说是一个很大的压力。因此,总而言之,性能很重要,尤其是在尝试使用超参数并且需要等待不止单个模型而是数十个模型的情况下。
PyTorch的表达能力很强,因此,与经过优化的TensorFlow图相比,高效处理代码似乎不那么神秘,但是仍可能执行缓慢及出现错误。例如,DQN损失计算的简单版本(该版本遍历每个批次样本)的运行速度大约是并行版本的1/3。但是,数据批的复制可能会使同一代码的速度变为原来的1/14,这是非常显著的。
根据长度、逻辑结构和可重用性,该示例代码分为三个模块,如下所示:
Chapter06/lib/wrappers.py
:Atari环境包装程序,主要来自OpenAI Baselines项目。Chapter06/lib/dqn_model.py
:DQN NN层,其结构与Nature杂志论文中的DeepMind DQN相同。Chapter06/02_dqn_pong.py
:主模块,包括训练循环、损失函数计算和经验回放缓冲区。
6.4.1 包装器
从资源的角度来看,使用RL处理Atari游戏的要求是很高的。为了使处理速度变快,DeepMind的论文中对Atari平台交互进行了几种转换。有些转换仅影响性能,但是有些改变了Atari平台的特性,使学习时间变长且变得不稳定。转换通常以各种OpenAI Gym包装器的形式实现,并且在不同的源中都有相同包装器的多种实现。我个人最喜欢的是OpenAI Baselines仓库,它是在TensorFlow中实现的一组RL方法和算法,并应用于流行的基准以建立比较方法的共同基础。该仓库可从https://github.com/openai/baselines
获得,而包装器可从文件https://github.com/openai/baselines/blob/master/baselines/common/atari_wrappers.py
中获取。
RL研究人员使用的最受欢迎的Atari转换包括:
- 将游戏中的一条命转变为单独的片段。一般来说,片段包含从游戏开始到屏幕出现“游戏结束”的所有步骤,这可以持续数千个游戏步骤(观察和动作)。通常,在街机游戏中,玩家被赋予几条命,可以提供几次游戏机会。这种转换将完整的片段分为玩家的每条命对应的片段。并非所有游戏都支持此功能(例如,Pong不支持),但是对于支持的环境,它通常有助于加快收敛,因为片段变得更短。
- 在游戏开始时,随机执行(最多30次)无操作的动作。这会跳过一些Atari游戏中与游戏玩法无关的介绍性屏幕。
- 每K步做出一个动作决策,其中K通常为4或3。在中间帧上,只需重复选择的动作。这可以使训练速度显著加快,因为使用NN处理每帧是消耗巨大的操作,但是相邻帧之间的差异通常很小。
- 取最后两帧中每个像素的最大值,并将其用作观察值。由于平台的限制,某些Atari游戏具有闪烁效果(Atari只能在一帧中显示有限数量的精灵图)。对于人眼来说,这种快速变化是不可见的,但是它们会使NN混乱。
- 在游戏开始时按FIRE。有些游戏(包括Pong和Breakout)要求用户按下FIRE按钮才能启动游戏。否则,环境将成为POMDP,因为从观察的角度来看,智能体无法知道是否已按下FIRE。
- 将每帧从具有三个彩色帧的210×160图像缩小到84×84单色图像。可以采用不同的方法。例如,DeepMind的论文将这种转换描述为从YCbCr颜色空间获取Y颜色通道,然后将整个图像重新缩小为84×84分辨率。其他一些研究人员则会进行灰度转换,裁剪图像的不相关部分,然后按比例缩小。在Baselines仓库(以及以下示例代码)中,将使用后一种方法。
- 将几个(通常是4个)后续帧堆在一起提供有关游戏对象的动态网络信息。前面已经讨论了这种方法,作为单个游戏帧中缺乏游戏动态信息的快速解决方案。
- 将奖励限制为–1、0和1。所获得的分数在各游戏之间可能会有很大差异。例如,在Pong中,对手每落后一球,可得1分。但是,在某些游戏中,例如KungFuMaster,每杀死一名敌人,可获得100的奖励。奖励值的分散使损失在不同游戏之间有完全不同的比例,这使得为一组游戏找到通用的超参数变得更加困难。要解决此问题,奖励需被限制在[–1 … 1]范围内。
- 将观察值从无符号字节转换为float32值。从模拟器获得的屏幕被编码为字节张量,其值为0~255,这不是NN的最佳表示。因此,需要将图像转换为浮点数并将值重新缩小至[0.0 … 1.0]范围。
在Pong示例中,我们不需要包装器(例如将游戏中的命转换为单独的片段和奖励裁剪的包装器),因此这些包装器不包含在示例代码中。但是,大家还是应该知道它们,以防想尝试其他游戏。有时,当DQN不收敛时,问题可能不是出自代码,而是出自错误的包装环境。我花了几天的时间调试由于游戏一开始没有按FIRE按钮而导致的收敛问题!
我们来看一下Chapter06/lib/wrappers.py
中各个包装器的实现:
在要求游戏启动的环境中,前面的包装器会按下FIRE按钮。除了按FIRE外,此包装器还会检查某些游戏中存在的几种极端情况。
该包装器组合了K帧中的重复动作和连续帧中的像素。
该包装器的目标是将来自模拟器的输入观察结果(通常具有RGB彩色通道,分辨率为210×160像素)转换为84×84灰度图像。它使用比色灰度转换(比简单的平均颜色通道更接近人类的颜色感知),调整图像大小以及裁剪顶部和底部来进行转换。
这个类沿着第一个维度将随后几帧叠加在一起,并将其作为观察结果返回。目的是使网络了解对象的动态,例如Pong中球的速度和方向或敌人的移动方式。这是非常重要的信息,无法从单个图像获得。
这个简单的包装器将观察的形状从HWC(高度,宽度,通道)更改为PyTorch所需的CHW(通道,高度,宽度)格式。张量的输入形状中颜色通道是最后一维,但是PyTorch的卷积层将颜色通道假定为第一维。
库中的最后一个包装器将观察数据从字节转换为浮点数,并将每个像素的值缩小到[0.0 … 1.0]的范围。
文件的末尾是一个简单函数,该函数根据名称创建环境并将所有必需的包装器应用到该环境。以上就是包装器,下面我们来看一下模型。
6.4.2 DQN模型
在Nature杂志上发表的模型有三个卷积层,然后是两个全连接层。所有层均由线性整流函数(Rectified Linear Unit, ReLU)非线性分开。模型的输出是环境中每个动作的Q值,没有应用非线性(因为Q值可以有任何值)。与逐个处理Q(s, a)并将观察值和动作反馈到网络以获得动作价值相比,通过网络一次计算所有Q值的方法有助于显著提高速度。
该模型的代码在Chapter06/lib/dqn_model.py
中:
为了能够以通用方式编写网络,将它分成两部分实现:convolution和sequential。PyTorch没有可以将3D张量转换为1D向量的“flatter”层,需要将卷积层输出到全连接层。这个问题在forward()
函数中得到解决,该函数可以将3D张量批处理为1D向量。
另一个小问题是,我们不知道给定输入形状的卷积层的输出值的准确数量,但是需要将此数字传递给第一个全连接层构造函数。一种可能的解决方案是对该数字进行硬编码,该数字是输入形状的函数(对于84×84的输入,卷积层的输出将有3136个值)。但是,这并不是最好的方法,因为代码对输入形状的变化将变得不那么健壮。更好的解决方案是用一个简单的函数_get_conv_out()
接受输入形状并将卷积层应用于这种形状的伪张量。该函数的结果将等于此应用程序返回的参数数量。这样会很快,因为此调用将在模型创建时完成,而且,它使代码更通用。
模型的最后一部分是forward()
函数,该函数接受4D输入张量。(第一维是批的大小;第二维是颜色通道,由后续帧叠加而成;第三维和第四维是图像尺寸。)
转换的应用分两步完成:首先将卷积层应用于输入,然后在输出上获得4D张量。这个结果被展平为两个维度:批大小以及该批卷积返回的所有参数(作为一个数字向量)。这是通过张量的view()
函数完成的,该函数让某一维为-1
,并作为其余参数的通配符。例如,假设有一个形状为(2, 3, 4)
的张量T
,它是由24个元素组成的3D张量,我们可以使用T.view(6, 4)
将其重塑为具有6行4列的2D张量。此操作不会创建新的内存对象,也不会在内存中移动数据,它只是改变了张量的高级形状。可以通过T.view(-1,4)
或T.view(6,-1)
获得相同的结果,这在张量第一维是批大小时非常方便。最后,将展平的2D张量传递到全连接层,以获取每个批输入的Q值。
6.4.3 训练
第三个模块包含经验回放缓冲区、智能体、损失函数的计算和训练循环本身。在讨论代码之前,需要对训练超参数进行一些说明。DeepMind在Nature发表的论文包含一张表格,其中包含用于在49个Atari游戏中训练其模型的超参数的所有详细信息。DeepMind在所有游戏中均让这些参数保持相同(但为每个游戏训练了单独的模型),意在证明该方法足够强大,可以通过一个模型架构和超参数来解决不同的游戏问题(具有不同的复杂性、动作空间、奖励结构和其他细节)。但是,我们的目标要简单得多:只想解决Pong游戏。
与Atari测试集中的其他游戏相比,Pong非常简单明了,因此论文中的超参数对于该任务来说过多。例如,为了在所有49款游戏中都获得最佳结果,DeepMind使用了一个百万观察值的回放缓冲区,该缓冲区需要大约20GB的RAM,并且要从环境中获取大量样本。
对于单个Pong游戏,论文中使用的ε衰减表也不是最好的。在训练中,DeepMind在从环境获得的前一百万帧中,将ε从1.0线性衰减到0.1。但是,笔者自己的实验表明,对于Pong而言,在前15万帧中衰减ε然后使其保持稳定就够了。回放缓冲区也可以小一些,1万次转移就足够了。
以下示例中使用了笔者自己的参数。这些与论文中的参数不同,但是可以使解决Pong的速度快大约10倍。在GeForce GTX 1080 Ti上,以下版本在1~2小时内的平均得分达到19.0,但是使用DeepMind的超参数,至少需要一天的时间。
当然,这种加速是针对特定环境的微调,并且可能破坏其他游戏的收敛性。大家可以自由使用Atari中的选项和其他游戏。
首先,导入所需的模块并定义超参数。
这两个值设置了训练的默认环境,以及最后100个片段的奖励边界以停止训练。如果需要,可以使用命令行重新定义环境名称。
这些参数定义以下内容:
- γ值用于Bellman近似(
GAMMA
)。 - 从回放缓冲区采样的批大小(
BATCH_SIZE
)。 - 回放缓冲区的最大容量(
REPLAY_SIZE
)。 - 开始训练前等待填充回放缓冲区的帧数(
REPLAY_START_SIZE
)。 - 本示例中使用的Adam优化器的学习率(
LEARNING_RATE
)。 - 将模型权重从训练模型同步到目标模型的频率,该目标模型用于获取Bellman近似中下一个状态的价值(
SYNC_TARGET_FRAMES
)。
最后一批超参数与ε衰减有关。为了进行适当的探索,在训练的早期阶段以ε = 1.0开始,这就可以随机选择所有动作。然后,在前15万帧期间,ε线性衰减至0.01,这对应于以1%的概率采取随机动作。最初的DeepMind论文也使用类似的方案,但是衰减的持续时间几乎是10倍(即在一百万帧后,ε = 0.01)。
下一部分代码定义了经验回放缓冲区,其目的是存储从环境中获得的状态转移(由观察、动作、奖励、完成标志和下一状态组成的元组)。在环境中每执行一步,都将状态转移情况推送到缓冲区中,仅保留固定数量的状态转移(本示例中为1万个)。为了进行训练,从回放缓冲区中随机抽取一批状态转移样本,这打破了环境中后续步骤之间的相关性。
大多数经验回放缓冲区代码非常简单,基本上利用了deque
类的功能以在缓冲区中维持给定数量的条目。在sample()
方法中,创建了一个随机索引列表,然后将采样的条目重新打包到NumPy数组中,以方便进行损失计算。
我们需要的下一个类是Agent
,它与环境交互并将交互结果保存到刚刚的经验回放缓冲区中:
在智能体初始化期间,需要存储对环境的引用和经验回放缓冲区,追踪当前的观察结果以及到目前为止累积的总奖励。
智能体的主要方法是在环境中执行一个步骤并将其结果存储在缓冲区中。为此,首先需要选择动作。利用概率ε(作为参数传递)采取随机动作;否则,将使用过去的模型获取所有可能动作的Q值,然后选择最佳值所对应的动作。
选择动作后,将其传递给环境以获取下一个观察结果和奖励,将数据存储在经验回放缓冲区中,然后处理片段结束的情况。如果通过此步骤到达片段末尾,则该函数的返回结果是总累积奖励,否则为None
。
现在是时候使用训练模块中的最后一个函数了,该函数可以计算采样批次的损失。该函数可以通过使用向量运算处理所有批样本,以最大限度地利用GPU并行性,与简单循环相比,它更难理解。然而,这种优化是有回报的,并行版本比批处理中的显式循环快两倍以上。
提醒一下,以下是需要计算的损失表达式(针对片段未结束的步骤):
最后一步用公式:
在参数中,我们传入了数组元组的批(由经验缓冲区中的sample()
方法重新打包)、正在训练的网络以及定期与训练网络同步的目标网络。
第一个模型(作为网络参数传递)用于计算梯度。tgt_net
参数用于计算下一个状态的价值,并且此计算不应影响梯度。为此,使用PyTorch张量的detach()
函数(见第3章)来防止梯度流入目标网络。
前面的代码简单明了,如果在参数中指定了CUDA设备,我们将带有批数据的NumPy数组包装在PyTorch张量中,然后将它们复制到GPU。
在上一行中,我们将观察结果传递给第一个模型,并使用gather()
张量操作提取所采取动作的特定Q值。gather()
调用的第一个参数是要对其进行收集的维度索引(本示例中,它等于1,对应于动作)。
第二个参数是要选择的元素的索引张量。需要额外调用unsqueeze()
和squeeze()
来计算索引参数,并摆脱创建的额外维度(索引应具有与正在处理的数据相同的维数)。在图6.3中,可以看到对gather()
情况的示例说明,其中批包含六个条目和四个动作。
图6.3 DQN计算损失过程中张量的变化
请记住,将gather()
的结果应用于张量是一个微分运算,该运算将使所有梯度都与损失值有关。
上一行代码将目标网络应用于下一个状态观察值,并按相同动作维度1来计算最大Q值。函数max()
返回最大值和这些值的索引(它同时计算max和argmax),这非常方便。但是,在本例中,我们只对价值感兴趣,因此只选结果的第一项。
在这里,我们提出一个简单但非常重要的点:如果状态转移发生在片段的最后一步,那么动作价值不会获得下一个状态的折扣奖励,因为没有可从中获得奖励的下一个状态。这看似微不足道,但在实践中非常重要,没有这个训练就不会收敛。
这行代码将值与其计算图分开,以防止梯度流入用于计算下一状态Q近似值的NN。
这很重要,因为如果不这样,损失的反向传播会同时影响当前状态和下一个状态的预测。但是,我们并不想影响下一个状态的预测,因为它们在Bellman方程中用来计算参考Q值。为了阻止梯度流入图的该分支中,使用张量的detach()
方法,该方法会返回与计算历史不相关联的张量。
最后,计算Bellman近似值和均方误差损失。这样损失函数的计算就结束了,其余的代码就是训练循环。
首先,创建一个命令行参数解析器。我们的脚本使我们能够启用CUDA并在与默认环境不同的环境中进行训练。
上述代码使用所有必需的包装器、将要训练的NN和具有相同结构的目标网络创建了环境。在一开始,使用不同的随机权重进行初始化,但这并不重要,因为每隔1000帧(大致相当于Pong的一个片段)同步一次。
然后,我们创建所需大小的经验回放缓冲区,并将其传给智能体。epsilon
最初初始化为1.0,但会随着迭代增加而减小。
在训练循环之前,我们要做的最后一件事是创建一个优化器、一个完整片段奖励的缓冲区、一个帧计数器和几个变量来跟踪速度以及达到的最佳平均奖励。每当平均奖励超过记录时,就将模型保存在文件中。
在训练循环的开始,计算完成的迭代次数,并根据规划减小epsilon
。epsilon
在给定帧数(EPSILON_DECAY_LAST_FRAME = 150k
)内线性下降,然后保持在EPSILON_FINAL = 0.01
的水平。
在这段代码中,我们让智能体在环境中执行一步(使用当前网络和epsilon
值)。仅当此步骤是片段的最后一步时,此函数才返回非None
结果。
在这种情况下,我们将报告进度。具体来说,是在控制台和TensorBoard中计算并显示以下值:
- 速度,即每秒处理的帧数。
- 运行的片段数。
- 最近100个片段的平均奖励。
epsilon
的当前值。
每当最近100个片段的平均奖励达到最高时,我们就报告此结果并保存模型参数。如果平均奖励超过了指定边界,就停止训练。对于Pong来说,边界是19.0,这意味着21场比赛中赢得19场以上。
这段代码检查缓冲区是否大到可以进行训练。在开始时,我们应该积累足够的数据,在本例中为1万次状态转移。下一个条件是每隔SYNC_TARGET_FRAMES
(默认情况下该值为1000)个数的帧将参数从主网络同步到目标网络。
训练循环的最后一部分代码非常简单,但是需要的执行时间最多:将梯度归零,从经验回放缓冲区中采样数据,计算损失,并执行优化步骤以最小化损失。
6.4.4 运行和性能
这个例子对资源要求很高。在Pong中,它需要大约40万帧才能达到平均奖励17(这意味着游戏的80%获胜)。从17提高到19需要相似数量的帧,因为学习进度将趋于饱和,并且模型很难再提高分数。因此,训练充分的话平均需要100万帧。在GTX 1080 Ti上,能达到每秒约120帧的速度,大约需要两个小时的训练。在CPU上,速度则要慢得多,大约为每秒9帧,大约需要一天半的时间训练。请记住,这是针对Pong游戏的,它相对容易解决。其他游戏需要数亿帧和100倍大的经验回放缓冲区。
在第8章中,我们将探讨研究人员自2015年以来发现的各种方法,这些方法可以帮助提高训练速度和数据效率。第9章将致力于提高RL方法性能的工程技巧。但是,对于Atari来说,需要资源和耐心。图6.4显示了训练动态图。
图6.4 最近100片段的平均奖励动态
在训练开始时:
在最初的1万步中,因为没有进行任何训练(代码中花费时间最多的操作),速度非常快。1万步之后,开始对训练批次进行采样,性能显著下降。
几百场比赛之后,DQN应该开始弄清楚如何在21场比赛中赢一两场。由于eps
减小,速度降低了,不仅需要将模型用于训练,还需要将其用于环境步骤:
最后,经过更多场比赛后,DQN终于可以统治并击败(不是非常复杂的)内置的Pong AI对手:
由于训练过程中的随机性,实际动态可能与此处显示的有所不同。在一些罕见的情况下(根据笔者自己的实验,每运行10次会出现一次),训练根本无法收敛,看起来奖励在很长一段时间都是–21。如果训练在前10万~20万迭代中没有显示出任何正向动态,那么应重新启动。
6.4.5 模型实战
训练过程只是整个过程的一半。我们的最终目标不仅仅是训练模型,我们也希望模型能够在玩游戏时表现良好。在训练期间,每次更新最近100场比赛的最大平均奖励时,都会将该模型保存到文件PongNoFrameskip-v4-best.dat
中。在Chapter06/03_dqn_play.py
文件中,有一个程序可以加载此模型文件并运行一个片段,以显示模型的动态。
该代码非常简单,但是像魔术一样神奇,可以看到几个具有百万参数的矩阵是如何通过观察像素来以超人的准确性玩Pong游戏的。
在一开始,导入熟悉的PyTorch和Gym模块。FPS
(每秒帧数)参数指定了显示帧的大致速度。
该脚本接受已保存模型的文件名,并允许指定Gym环境(当然,模型和环境必须匹配)。此外,还可以通过选项-r
传递不存在目录名称,该目录将用于保存游戏视频(使用Monitor
包装器)。默认情况下,脚本仅显示帧,但是如果要将模型的游戏上传到YouTube,则用-r
可能很方便。
前面的代码无须注释也很清楚,它创建环境和模型,然后从传递给参数的文件中加载权重。需要将参数map_location
传递给torch.load()
函数,以将加载的张量从GPU映射到CPU。默认情况下,torch
会尝试将张量加载到保存张量的设备上,但是如果将模型从用于训练的计算机(带有GPU)复制到没有GPU的笔记本电脑,则需要重新映射位置。本示例根本没有使用GPU,因为没有加速推理也足够快。
这段基本是训练代码的Agent
类的play_step()
方法的复制,没有选择ε-greedy动作。只是将观察结果传递给智能体,然后选择具有最大价值的动作。这里唯一的新事物是环境中的render()
方法,这是Gym中显示当前观察值的标准方法(为此,需要有图形用户界面(Graphical User Interface,GUI))。
其余代码也很简单。我们将动作传递给环境,计算总奖励,并在片段结束时停止循环。片段结束后,将显示总奖励以及智能体执行动作的次数。
在YouTube播放列表(https://www.youtube.com/playlist?list=PLMVwuZENsfJklt4vCltrWq0KV9aEZ3ylu)中,你可以找到训练各个阶段的游戏记录。