深度强化学习实用指南第三版(五)
原文:
annas-archive.org/md5/28625da26760ed246b61fc08b36918f7译者:飞龙
第十八章:高级探索
在本章中,我们将讨论强化学习(RL)中的探索主题。书中多次提到,探索/利用困境是强化学习中的一个基本问题,对于高效学习非常重要。然而,在之前的例子中,我们使用了一种相当简单的探索环境的方法,即大多数情况下的 𝜖-greedy 行动选择。现在是时候深入探讨强化学习中的探索子领域,因为更复杂的环境可能需要比 𝜖-greedy 方法更好的探索策略。
更具体地,我们将涵盖以下关键主题:
-
为什么探索是强化学习中如此基本的话题
-
𝜖-greedy 方法的有效性
-
替代方法及其在不同环境中的工作原理
我们将实现所描述的方法,解决一个名为 MountainCar 的玩具问题,尽管它依然具有挑战性。这将帮助我们更好地理解这些方法、它们如何实现以及它们的行为。之后,我们将尝试解决一个来自 Atari 套件的更难问题。
为什么探索很重要
本书讨论了许多环境和方法,几乎每一章都提到了探索。很可能你已经对为什么有效地探索环境很重要有了一些想法,所以我将只讨论主要的原因。
在此之前,定义“有效探索”可能会有帮助。在理论强化学习中,已有严格的定义,但高层次的概念既简单又直观。当我们不再浪费时间在已经被智能体见过并且熟悉的环境状态中时,探索就是有效的。智能体不应一遍遍做相同的动作,而是需要寻找新的经验。正如我们之前讨论过的,探索必须与利用相平衡,后者是相反的概念,指的是利用我们的知识以最有效的方式获得最好的奖励。现在让我们快速讨论一下为什么我们最初会对有效探索感兴趣。
首先,良好的环境探索可能对我们学习良好策略的能力产生根本性影响。如果奖励稀疏,且智能体只有在某些罕见条件下才能获得良好的奖励,那么它可能在许多回合中只会经历一次正奖励,因此学习过程有效且充分地探索环境的能力,可能会带来更多能够从中学习到的良好奖励样本。
在一些情况下,这种情况在强化学习的实际应用中非常常见,缺乏良好的探索可能意味着代理根本无法体验到正向奖励,这样其他一切就变得无用。如果你没有好的样本来学习,你可以拥有最有效的强化学习方法,但它唯一能学到的就是没有办法获得好的奖励。这正是许多实际中有趣的问题的情况。例如,我们将在本章稍后详细了解 MountainCar 环境,它的动力学非常简单,但由于奖励稀疏,解决起来相当棘手。
另一方面,即使奖励不是稀疏的,有效的探索也能提高训练速度,因为它有助于更好的收敛性和训练稳定性。这是因为我们从环境中采样变得更加多样化,且与环境的通信需求减少。因此,我们的强化学习方法有机会在更短的时间内学习到更好的策略。
𝜖-greedy 有什么问题吗?
在全书中,我们使用了𝜖-greedy 探索策略作为一种简单但仍然可接受的环境探索方法。𝜖-greedy 背后的基本思想是以𝜖的概率采取随机动作;否则,(以 1 −𝜖的概率)我们按照策略(贪婪地)执行动作。通过调整超参数 0 ≤𝜖 ≤ 1,我们可以改变探索的比例。这种方法在本书中描述的大多数基于值的方法中都有使用。类似的思想也被应用于基于策略的方法,当我们的网络返回一个动作的概率分布时。为了防止网络对动作变得过于确定(通过为某个特定动作返回 1 的概率,为其他动作返回 0 的概率),我们添加了熵损失,它实际上是概率分布的熵乘以某个超参数。在训练的早期阶段,这个熵损失推动我们的网络采取随机动作(通过正则化概率分布)。但在后期,当我们足够探索了环境且奖励相对较高时,策略梯度就主导了这种熵正则化。但是,这个超参数需要调整才能正常工作。
从高层次来看,两种方法做的事情是相同的:为了探索环境,我们将随机性引入到我们的动作中。然而,最近的研究表明,这种方法距离理想状态还有很大差距:
-
在值迭代方法中,轨迹中的某些随机动作会引入偏差,影响我们对 Q 值的估计。贝尔曼方程假设下一个状态的 Q 值是通过选择 Q 值最大的动作来获得的。换句话说,轨迹的其余部分应来自我们的最优行为。然而,使用𝜖-贪婪策略时,我们可能不会选择最优动作,而是随机选择一个动作,这段轨迹将会长期保存在回放缓冲区中,直到我们的𝜖值衰减并且旧样本被从缓冲区中删除。在此之前,我们将学习到错误的 Q 值。
-
随着随机动作的注入,我们的策略在每一步都会发生变化。根据𝜖值或熵损失系数定义的频率,我们的轨迹会不断地在随机策略和当前策略之间切换。这可能导致在需要多个步骤才能到达环境状态空间中某些孤立区域时,状态空间的覆盖不充分。
为了说明最后一个问题,让我们考虑一个简单的例子,取自 Strehl 和 Littman 的论文《基于模型的区间估计分析:马尔可夫决策过程》,该论文于 2008 年发表[SL08]。这个例子称为“River Swim”,它模拟了一个智能体需要跨越的河流。环境包含六个状态和两个动作:左移和右移。状态 1 和状态 6 位于河流的两侧,状态 2 到状态 5 位于水中。
图 18.1 显示了前两个状态(状态 1 和状态 2)的转移图:
图 18.1:River Swim 环境的前两个状态的转移
在第一个状态(标有“1”的圆圈)中,智能体站在河岸上。唯一的动作是右移(通过实线表示),意味着进入河流并逆流游泳到达状态 2。然而,水流很强,从状态 1 向右游泳的动作成功的概率只有 60%(从状态 1 到状态 2 的实线)。以 40%的概率,水流将我们留在状态 1(连接状态 1 与自身的实线)。
在第二个状态(标有“2”的圆圈)中,我们有两个动作:左移,通过虚线连接状态 2 和状态 1(该动作总是成功的,因为当前的流水会将我们冲回河岸),以及右移(虚线),意味着逆流游泳到达状态 3。如前所述,逆流游泳很困难,因此从状态 2 到状态 3 的概率仅为 35%(连接状态 2 和状态 3 的虚线)。以 60%的概率,我们的左移动作最终会停留在同一状态(连接状态 2 和状态 2 的弯曲虚线)。但有时,尽管我们努力,左移动作最终会使我们回到状态 1,这种情况发生的概率为 5%(连接状态 2 和状态 1 的弯曲虚线)。
如我所说,River Swim 有六个状态,但状态 3、4 和 5 的转换与状态 2 相同。最后一个状态 6 与状态 1 相似,因此在该状态下只有一个动作可用:左,即游回去。在图 18.2 中,你可以看到完整的转换图(这只是我们之前见过的图的克隆,右转动作的转换用实线表示,左转动作的转换用虚线表示):
图 18.2:River Swim 环境的完整转换图
就奖励而言,代理在状态 1 到状态 5 之间的转换获得 1 的小奖励,但进入状态 6 时会获得 1,000 的高奖励,作为对逆流游泳所有努力的补偿。
尽管环境本身很简单,但其结构为 𝜖-贪婪策略能够完全探索状态空间带来了问题。为了检查这一点,我实现了这个环境的一个非常简单的模拟,你可以在 Chapter18/riverswim.py 中找到它。模拟中的代理总是随机行动(𝜖 = 1),模拟结果是各种状态访问的频率。代理在一个回合中可以采取的步数限制为 10,但可以通过命令行进行更改。我们不会在这里详细讲解整个代码;你可以在 GitHub 仓库中查看。现在,我们来看一下实验结果:
Chapter18$ ./riverswim.py
1: 40
2: 39
3: 17
4: 3
5: 1
6: 0
在之前的输出中,每一行显示了状态编号以及在模拟过程中访问该状态的次数。使用默认的命令行选项,进行了 100 步(10 个回合)的模拟。正如你所看到的,代理从未到达状态 6,并且仅在状态 5 中出现过一次。通过增加回合数,情况稍有改善,但并没有太大变化:
Chapter18$ ./riverswim.py -n 1000
1: 441
2: 452
3: 93
4: 12
5: 2
6: 0
模拟了 10 倍回合后,我们仍然没有访问状态 6,因此代理完全不知道那里有如此高的奖励。
只有在模拟了 10,000 个回合后,我们才成功到达状态 6,但仅仅 5 次,占所有步骤的 0.05%:
Chapter18$ ./riverswim.py -n 10000
1: 4056
2: 4506
3: 1095
4: 281
5: 57
6: 5
因此,即使采用最好的强化学习方法,训练的效率也不太可能很高。此外,在这个例子中,我们只有六个状态。想象一下,如果有 20 或 50 个状态,效率会低到什么程度,而这并非不可能;例如,在 Atari 游戏中,可能需要做出数百个决策才能发生一些有趣的事情。如果你愿意,可以使用 riverswim.py 工具进行实验,工具允许你更改随机种子、回合中的步数、总步数,甚至环境中的状态数。
这个简单的例子说明了在探索中随机动作的问题。通过随机行动,我们的智能体并没有积极地去探索环境,它只是希望随机动作能为其经验带来一些新东西,但这并不总是最好的做法。
现在让我们讨论一些更高效的探索方法。
探索的替代方法
在本节中,我们将为您提供一组探索问题的替代方法的概述。这并不是现有方法的详尽列表,而是提供一个领域概况。
我们将探索以下三种探索方法:
-
策略中的随机性,当我们在获取样本时向所使用的策略中添加随机性。本方法家族中的方法是噪声网络,我们在第八章中已经讲过。
-
基于计数的方法,它们记录智能体在特定状态下出现的次数。我们将检查两种方法:直接计数状态和伪计数方法。
-
基于预测的方法,它们尝试根据状态和预测的质量来预测某些内容。我们可以判断智能体对该状态的熟悉程度。为了说明这种方法,我们将通过观察策略蒸馏方法来进行说明,该方法在像《蒙特祖玛的复仇》这样的难度较大的 Atari 游戏中取得了最先进的成果。
在实现这些方法之前,让我们尝试更详细地理解它们。
噪声网络
让我们从一个我们已经熟悉的方法开始。我们在第八章中提到过噪声网络方法,当时我们提到 Hessel 等人[Hes+18]并讨论了深度 Q 网络(DQN)的扩展。其思路是向网络的权重中添加高斯噪声,并通过反向传播来学习噪声参数(均值和方差),这与我们学习模型的权重的方式相同。在那一章中,这种简单的方法显著提升了 Pong 游戏的训练效果。
从高层次看,这可能看起来与𝜖-贪婪方法非常相似,但 Fortunato 等人[For+17]声称存在差异。这个差异在于我们如何将随机性应用到网络中。在𝜖-贪婪方法中,随机性是添加到动作中的。而在噪声网络中,随机性被注入到网络的部分(接近输出的几个全连接层),这意味着将随机性添加到我们当前的策略中。此外,噪声的参数可能会在训练过程中学习,因此训练过程可能会根据需要增加或减少这种策略的随机性。
根据论文,噪声层中的噪声需要不时进行采样,这意味着我们的训练样本不是由当前策略生成的,而是由多个策略的集成生成的。这样一来,我们的探索变得有针对性,因为加到权重上的随机值会产生不同的策略。
基于计数的方法
这一类方法基于一个直觉:访问那些之前没有被探索过的状态。在简单的情况下,当状态空间不太大并且不同的状态很容易区分时,我们只需计算看到状态或状态+动作的次数,并倾向于前往那些计数较低的状态。
这可以作为额外的奖励来实现,这种奖励不是来自环境,而是来自状态的访问次数。在文献中,这种奖励被称为内在奖励。在这个语境中,环境中的奖励被称为外在奖励。制定这种奖励的一种方式是使用强盗探索方法:。这里,Ñ(s)是我们看到状态 s 的次数或伪计数,值 c 定义了内在奖励的权重。
如果状态的数量很少,比如在表格学习的情况下(我们在第五章讨论过),我们可以直接对其进行计数。在更困难的情况下,当状态太多时,需要引入一些对状态的转换,例如哈希函数或某些状态的嵌入(我们稍后会在本章更详细地讨论)。
对于伪计数方法,Ñ(s)被分解为密度函数和访问的状态总数,给定Ñ(s) = ρ(x)n(x),其中ρ(x)是“密度函数”,表示状态 x 的可能性,并通过神经网络进行近似。有几种不同的方法可以做到这一点,但它们可能很难实现,所以我们在本章不会讨论复杂的情况。如果你感兴趣,可以参考 Georg Ostrovski 等人发表的《基于计数的探索与神经密度模型》[Ost+17]。
引入内在奖励的一个特殊情况叫做好奇心驱动的探索,当我们完全不考虑来自环境的奖励时。在这种情况下,训练和探索完全由智能体经验的新颖性驱动。令人惊讶的是,这种方法可能非常有效,不仅能发现环境中的新状态,还能学习出相当不错的策略。
基于预测的方法
第三类探索方法基于从环境数据中预测某些东西的另一个想法。如果智能体能够做出准确的预测,意味着智能体已经在这种情况下经历了足够多的时间,因此不值得再去探索它。
但是如果发生了一些不寻常的情况,且我们的预测偏差很大,这可能意味着我们需要关注当前所处的状态。做这件事有很多不同的方式,但在本章中,我们将讨论如何实现这一方法,正如 Burda 等人在 2018 年提出的《通过随机网络蒸馏进行探索》一文中所提出的那样[Bur+18]。作者们在所谓的硬探索游戏中(如 Atari)达到了最先进的结果。
论文中使用的方法非常简单:我们添加了内在奖励,该奖励通过一个神经网络(NN)(正在训练中)从另一个随机初始化(未训练)神经网络预测输出的能力来计算。两个神经网络的输入是当前的观察值,内在奖励与预测的均方误差(MSE)成正比。
MountainCar 实验
在这一部分,我们将尝试在一个简单但仍具有挑战性的环境中实现并比较不同探索方法的效果,这个环境可以归类为一个“经典强化学习”问题,与我们熟悉的 CartPole 问题非常相似。但与 CartPole 相比,MountainCar 问题在探索角度上要困难得多。
问题的示意图如图 18.3 所示,图中有一辆小车从山谷的底部开始。汽车可以向左或向右移动,目标是到达右侧山顶。
图 18.3:MountainCar 环境
这里的诀窍在于环境的动态和动作空间。为了到达山顶,动作需要以特定的方式应用,使汽车前后摆动以加速。换句话说,智能体需要在多个时间步骤内应用动作,使汽车加速并最终到达山顶。
显然,这种动作协调并不是通过随机动作轻松实现的,因此从探索的角度来看,这个问题很难,且与我们的 River Swim 示例非常相似。
在 Gym 中,这个环境的名称是 MountainCar-v0,且它有一个非常简单的观察和动作空间。观察值只有两个数字:第一个数字表示汽车的水平位置,第二个数字表示汽车的速度。动作可以是 0、1 或 2,其中 0 表示将汽车推向左侧,1 表示不施加任何力量,2 表示将汽车推向右侧。以下是一个在 Python REPL 中非常简单的示意:
>>> import gymnasium as gym
>>> e = gym.make("MountainCar-v0")
>>> e.reset()
(array([-0.56971574, 0\. ], dtype=float32), {})
>>> e.observation_space
Box([-1.2 -0.07], [0.6 0.07], (2,), float32)
>>> e.action_space
Discrete(3)
>>> e.step(0)
(array([-0.570371 , -0.00065523], dtype=float32), -1.0, False, False, {})
>>> e.step(0)
(array([-0.57167655, -0.00130558], dtype=float32), -1.0, False, False, {})
>>> e.step(0)
(array([-0.57362276, -0.00194625], dtype=float32), -1.0, False, False, {})
正如你所看到的,在每一步中,我们获得的奖励是-1,因此智能体需要学习如何尽快到达目标,以便获得尽可能少的总负奖励。默认情况下,步数限制为 200,所以如果我们没有达到目标(这通常是发生的情况),我们的总奖励就是−200。
DQN + 𝜖-greedy
我们将使用的第一个方法是我们传统的 𝜖-greedy 探索方法。它在源文件 Chapter18/mcar_dqn.py 中实现。我不会在这里包含源代码,因为你已经很熟悉它了。这个程序在 DQN 方法的基础上实现了各种探索策略,允许我们通过 -p 命令行选项在它们之间进行选择。要启动正常的 𝜖-greedy 方法,需要传递 -p egreedy 选项。在训练过程中,我们将 𝜖 从 1.0 降低到 0.02,持续进行 10⁵ 步训练。
训练速度相当快;进行 10⁵ 步训练只需两到三分钟。但从图 18.4 和图 18.5 中展示的图表可以明显看出,在这 10⁵ 步(即 500 回合)中,我们一次都没有达到目标状态。这是个坏消息,因为我们的 𝜖 已经衰减,意味着我们在未来不会进行更多的探索。
图 18.4:在 DQN 训练过程中使用 𝜖-greedy 策略时的奖励(左)和步数(右)
图 18.5:训练过程中 𝜖(左)和损失(右)的变化
我们仍然执行的 2% 随机动作远远不够,因为达到山顶需要数十步协调的动作(MountainCar 上的最佳策略总奖励大约为 -80)。现在我们可以继续训练几百万步,但我们从环境中获得的数据将只是回合,每回合需要 200 步,且总奖励为 -200。这再次说明了探索的重要性。无论我们使用什么训练方法,如果没有适当的探索,我们可能根本无法训练成功。那么,我们应该怎么做呢?如果我们想继续使用 𝜖-greedy,唯一的选择就是进行更长时间的探索(通过调整 𝜖 衰减的速度)。你可以尝试调整 -p egreedy 模式的超参数,但我走到了极端,实施了 -p egreedy-long 超参数集。在这个方案中,我们将 𝜖 保持为 1.0,直到至少有一个回合的总奖励超过 -200。完成这个目标后,我们开始正常训练,将 𝜖 从 1.0 降低到 0.02,持续训练 10⁶ 帧。在初始探索阶段,由于没有进行训练,这个过程通常会比正常训练快 5 到 10 倍。要在这种模式下开始训练,我们使用以下命令行:./mcar_dqn.py -n t1 -p egreedy-long。
不幸的是,即使改进了 𝜖-greedy 策略,仍然由于环境的复杂性未能解决问题。我让这个版本运行了五个小时,但在 500k 个回合后,它仍然没有遇到过一次目标,所以我放弃了。当然,你可以尝试更长时间。
DQN + 噪声网络
为了将噪声网络方法应用于我们的 MountainCar 问题,我们只需将网络中的两层之一替换为 NoisyLinear 类,最终架构如下:
MountainCarNoisyNetDQN(
(net): Sequential(
(0): Linear(in_features=2, out_features=128, bias=True)
(1): ReLU()
(2): NoisyLinear(in_features=128, out_features=3, bias=True)
)
)
NoisyLinear 类与第八章版本的唯一区别在于,此版本有一个显式的方法 sample_noise() 来更新噪声张量,因此我们需要在每次训练迭代时调用此方法;否则,噪声将在训练过程中保持不变。这个修改是为了未来与基于策略的方法进行实验所需的,这些方法要求噪声在相对较长的轨迹期间保持恒定。无论如何,这个修改很简单,我们只需不时调用这个方法。在 DQN 方法中,它会在每次训练迭代时被调用。和第八章一样,NoisyLinear 的实现来自于 TorchRL 库。代码与之前相同,所以要激活噪声网络,你需要使用 -p noisynet 命令行来运行训练。
在图 18.6 中,你可以看到三小时训练的图表:
图 18.6:DQN 带噪声网络探索的训练奖励(左)和测试步骤(右)
如你所见,训练过程未能达到代码中要求的平均测试奖励 -130,但在仅仅 7k 训练步(20 分钟训练)后,我们发现了目标状态,相比于 𝜖-greedy 方法在经过 5 小时的试错后依然没有找到任何目标状态,这已是很大的进步。
从测试步骤图(图 18.6 右侧)中我们可以看到,有一些测试的步骤数不到 100 步,这非常接近最优策略。但这些测试次数不足以将平均测试奖励推低到 -130 以下。
DQN + 状态计数
我们将应用于 DQN 方法的最后一种探索技术是基于计数的。由于我们的状态空间只有两个浮点值,我们将通过将数值四舍五入到小数点后三位来离散化观察,这应该能够提供足够的精度来区分不同的状态,但仍能将相似的状态聚集在一起。对于每个单独的状态,我们将记录该状态出现的次数,并利用这个计数为智能体提供额外的奖励。对于一个离策略方法来说,在训练过程中修改奖励可能不是最好的做法,但我们将会考察其效果。
如同之前一样,我不会提供完整的源代码;我只会强调与基础版本的不同之处。首先,我们为环境应用包装器,以跟踪计数器并计算内在奖励值。你可以在 lib/common.py 模块中找到包装器的代码,下面是它的展示。
让我们先来看一下构造函数:
class PseudoCountRewardWrapper(gym.Wrapper):
def __init__(self, env: gym.Env, hash_function = lambda o: o,
reward_scale: float = 1.0):
super(PseudoCountRewardWrapper, self).__init__(env)
self.hash_function = hash_function
self.reward_scale = reward_scale
self.counts = collections.Counter()
在构造函数中,我们传入要包装的环境、可选的哈希函数(用于观察结果)以及固有奖励的规模。我们还创建了一个计数器容器,它将哈希后的状态映射为我们看到该状态的次数。
然后,我们定义辅助函数:
def _count_observation(self, obs) -> float:
h = self.hash_function(obs)
self.counts[h] += 1
return np.sqrt(1/self.counts[h])
这个函数将计算状态的固有奖励值。它对观察结果应用哈希,更新计数器,并使用我们已经看到的公式计算奖励。
包装器的最后一个方法负责环境的步骤:
def step(self, action):
obs, reward, done, is_tr, info = self.env.step(action)
extra_reward = self._count_observation(obs)
return obs, reward + self.reward_scale * extra_reward, done, is_tr, info
在这里,我们调用辅助函数来获取奖励,并返回外部奖励和固有奖励组件的总和。
要应用这个包装器,我们需要将哈希函数传递给它:
def counts_hash(obs: np.ndarray):
r = obs.tolist()
return tuple(map(lambda v: round(v, 3), r))
三位数字可能太多了,所以你可以尝试使用另一种方式来哈希状态。
要开始训练,请将 -p counts 参数传递给训练程序。在图 18.7 中,你可以看到带有训练和测试奖励的图表。由于训练环境被我们包装在伪计数奖励包装器中,因此训练期间的值高于测试期间的值。
图 18.7:DQN 训练奖励(左)和测试奖励(右),带伪计数奖励加成
如你所见,我们未能通过这种方法获得 -130 的平均测试奖励,但我们非常接近。它只用了 10 分钟就发现了目标状态,这也是相当令人印象深刻的。
PPO 方法
我们将在 MountainCar 问题上进行的另一组实验与在线策略方法 Proximal Policy Optimization(PPO)相关,我们在第十六章中讨论过。选择这个方法的动机有几个:
-
首先,正如你在 DQN 方法 + 噪声网络的案例中看到的那样,当好的示例很少时,DQN 在快速适应这些示例方面会遇到困难。这可以通过增加重放缓冲区的大小并切换到优先缓冲区来解决,或者我们可以尝试使用在线策略方法,这些方法根据获得的经验立即调整策略。
-
选择这个方法的另一个原因是训练过程中奖励的修改。基于计数的探索和策略蒸馏引入了固有奖励组件,这个组件可能会随着时间的推移而变化。基于值的方法可能对基础奖励的修改比较敏感,因为它们基本上需要在训练过程中重新学习值。而在线策略方法不应该有任何问题,因为奖励的增加只是使具有较高奖励的样本在策略梯度中更加重要。
-
最后,检查我们的探索策略在两种 RL 方法家族中的表现是很有趣的。
为了实现这种方法,在文件 Chapter18/mcar_ppo.py 中,我们有一个结合了各种探索策略的 PPO 实现,应用于 MountainCar。代码与第十六章中的 PPO 实现差别不大,所以我不会在这里重复。要启动没有额外探索调整的普通 PPO,你应该运行命令./mcar_ppo.py -n t1 -p ppo。在这个版本中,没有专门做探索的操作——我们完全依赖于训练开始时的随机权重初始化。
提醒一下,PPO 属于策略梯度方法家族,在训练过程中限制旧策略与新策略之间的 Kullback-Leibler 散度,避免了剧烈的策略更新。我们的网络有两个部分:演员和评论家。演员网络返回我们行为的概率分布(我们的策略),评论家估计状态的价值。评论家使用均方误差(MSE)损失进行训练,而演员则由我们在第十六章讨论的 PPO 代理目标驱动。除了这两种损失,我们通过应用由超参数β缩放的熵损失来对策略进行正则化。到目前为止没有什么新内容。以下是 PPO 网络结构:
MountainCarBasePPO(
(actor): Sequential(
(0): Linear(in_features=2, out_features=64, bias=True)
(1): ReLU()
(2): Linear(in_features=64, out_features=3, bias=True)
)
(critic): Sequential(
(0): Linear(in_features=2, out_features=64, bias=True)
(1): ReLU()
(2): Linear(in_features=64, out_features=1, bias=True)
)
)
我在训练了三个小时后停止了训练,因为没有看到任何改进。目标状态在一个小时和 30k 轮次后找到了。图 18.8 中的图表展示了训练过程中的奖励动态:
图 18.8:在普通 PPO 上的训练奖励(左)和测试奖励(右)
由于 PPO 的结果并不十分令人印象深刻,让我们尝试通过额外的探索技巧来扩展它。
PPO + 噪声网络
与 DQN 方法类似,我们可以将噪声网络探索方法应用到 PPO 方法中。为此,我们需要用 NoisyLinear 层替换演员网络的输出层。只有演员网络需要受到影响,因为我们只希望将噪声注入到策略中,而不是价值估计中。
有一个微妙的细节与噪声网络的应用有关:即随机噪声需要在哪个地方进行采样。在第八章中,当你首次接触噪声网络时,噪声是在每次 forward() 通过 NoisyLinear 层时进行采样的。根据原始研究论文,对于离策略方法,这是可以的,但对于在策略方法,它需要以不同的方式进行。实际上,当我们进行在策略训练时,我们获得的是当前策略产生的训练样本,并计算策略梯度,这应该推动策略朝着改进的方向前进。噪声网络的目标是注入随机性,但正如我们所讨论的,我们更倾向于有针对性的探索,而不是在每一步之后就随机地改变策略。考虑到这一点,NoisyLinear 层中的随机成分不需要在每次 forward() 传递之后更新,而应该更少的频率进行更新。在我的代码中,我在每个 PPO 批次(即 2,048 次转换)时重新采样噪声。
和之前一样,我训练了 PPO+NoisyNets 3 小时。但在这种情况下,目标状态在 30 分钟和 18k 回合后就被找到,这是一个更好的结果。此外,根据训练步数统计,训练过程成功地让小车以最优方式行驶了几次(步数小于 100)。但是,这些成功并没有导致最终的最优策略。图 18.9 中的图表展示了训练过程中的奖励动态:
图 18.9:PPO 使用噪声网络的训练奖励(左)和测试奖励(右)
PPO + 状态计数
在这种情况下,使用三位数哈希的基于计数的方式在 PPO 方法中得到了完全相同的实现,并可以通过在训练过程中传递 -p counts 来触发。
在我的实验中,该方法能够在 1.5 小时内解决环境问题(获得平均奖励高于 -130),并且需要 61k 回合。以下是控制台输出的最后部分:
Episode 61454: reward=-159.17, steps=168, speed=4581.6 f/s, elapsed=1:37:18
Episode 61455: reward=-158.46, steps=164, speed=4609.0 f/s, elapsed=1:37:18
Episode 61456: reward=-158.41, steps=164, speed=4582.3 f/s, elapsed=1:37:18
Episode 61457: reward=-152.73, steps=158, speed=4556.4 f/s, elapsed=1:37:18
Episode 61458: reward=-154.08, steps=159, speed=4548.1 f/s, elapsed=1:37:18
Episode 61459: reward=-154.85, steps=162, speed=4513.0 f/s, elapsed=1:37:18
Test done: got -91.000 reward after 91 steps, avg reward -129.999
Reward boundary has crossed, stopping training. Congrats!
如图 18.10 所示,从图表中可以看到,训练在 23k 回合后发现了目标状态。之后又花了 40k 回合来优化策略,达到了最优步数:
图 18.10:PPO 使用伪计数奖励加成的训练奖励(左)和测试奖励(右)
PPO + 网络蒸馏
作为我们 MountainCar 实验中的最终探索方法,我实现了 Burda 等人提出的网络蒸馏方法[Bur+18]。在这种方法中,引入了两个额外的神经网络(NN)。这两个网络都需要将观察值映射为一个数字,方式与我们的价值头部相同。不同之处在于它们的使用方式。第一个神经网络是随机初始化并保持未训练的,这将成为我们的参考神经网络。第二个神经网络经过训练,以最小化第二个和第一个神经网络之间的均方误差(MSE)损失。此外,神经网络输出之间的绝对差异作为内在奖励组件。
这背后的想法是,代理程序探索某些状态得越好,我们第二个(训练过的)神经网络就越能预测第一个(未经训练的)神经网络的输出。这将导致将较小的内在奖励添加到总奖励中,从而减少样本分配的策略梯度。
在论文中,作者建议训练单独的价值头来预测内在和外在奖励成分,但在这个例子中,我决定保持简单,只是在包装器中添加了这两个奖励,就像我们在基于计数的探索方法中所做的那样。这样可以最小化代码的修改数量。
关于那些额外的神经网络架构,我做了一个小实验,并尝试了两个神经网络的几种架构。最佳结果是参考神经网络具有三层,训练神经网络只有一层。这有助于防止训练神经网络的过拟合,因为我们的观察空间并不是很大。两个神经网络都实现在 lib/ppo.py 模块的 MountainCarNetDistillery 类中:
class MountainCarNetDistillery(nn.Module):
def __init__(self, obs_size: int, hid_size: int = 128):
super(MountainCarNetDistillery, self).__init__()
self.ref_net = nn.Sequential(
nn.Linear(obs_size, hid_size),
nn.ReLU(),
nn.Linear(hid_size, hid_size),
nn.ReLU(),
nn.Linear(hid_size, 1),
)
self.ref_net.train(False)
self.trn_net = nn.Sequential(
nn.Linear(obs_size, 1),
)
def forward(self, x):
return self.ref_net(x), self.trn_net(x)
def extra_reward(self, obs):
r1, r2 = self.forward(torch.FloatTensor([obs]))
return (r1 - r2).abs().detach().numpy()[0][0]
def loss(self, obs_t):
r1_t, r2_t = self.forward(obs_t)
return F.mse_loss(r2_t, r1_t).mean()
除了返回两个神经网络输出的 forward()方法外,该类还包括两个帮助方法,用于计算内在奖励和获取两个神经网络之间的损失。
要开始训练,需要将参数 -p distill 传递给 mcar_ppo.py 程序。在我的实验中,解决问题需要 33k 个周期,比噪声网络少了近两倍。正如早些时候讨论的那样,我的实现中可能存在一些错误和低效性,因此欢迎您修改以使其更快更高效:
Episode 33566: reward=-93.27, steps=149, speed=2962.8 f/s, elapsed=1:23:48
Episode 33567: reward=-82.13, steps=144, speed=2968.6 f/s, elapsed=1:23:48
Episode 33568: reward=-83.77, steps=143, speed=2973.7 f/s, elapsed=1:23:48
Episode 33569: reward=-93.59, steps=160, speed=2974.0 f/s, elapsed=1:23:48
Episode 33570: reward=-83.04, steps=143, speed=2979.7 f/s, elapsed=1:23:48
Episode 33571: reward=-97.96, steps=158, speed=2984.5 f/s, elapsed=1:23:48
Episode 33572: reward=-92.60, steps=150, speed=2989.8 f/s, elapsed=1:23:48
Test done: got -87.000 reward after 87 steps, avg reward -129.549
Reward boundary has crossed, stopping training. Congrats!
显示有关训练和测试奖励的图表如图 18.11 所示。在图 18.12 中,显示了总损失和蒸馏损失。
Figure 18.11: PPO 与网络蒸馏的训练奖励(左)和测试奖励(右)
Figure 18.12: 总损失(左)和蒸馏损失(右)
与之前一样,由于内在奖励成分的存在,训练周期在图中有更高的奖励。从蒸馏损失图中可以明显看出,在代理程序发现目标状态之前,一切都是无聊和可预测的,但一旦它找出如何在 200 步之前结束这一情况,损失就显著增加。
方法比较
为了简化我们在 MountainCar 上进行的实验比较,我将所有数字放入以下表格中:
| Method | 找到目标状态 | 解决 | ||
|---|---|---|---|---|
| Episodes | Time | Episodes | Time | |
| DQN + 𝜖-greedy | x | x | x | x |
| DQN + noisy nets | 8k | 15 min | x | x |
| PPO | 40k | 60 min | x | x |
| PPO + noisy nets | 20k | 30 min | x | x |
| PPO + counts | 25k | 36 min | 61k | 90 min |
| PPO + distillation | 16k | 36 min | 33k | 84 min |
Table 18.1: 实验总结
正如你所看到的,带有探索扩展的 DQN 和 PPO 都能够解决 MountainCar 环境。具体方法的选择取决于你和你具体的情况,但重要的是要意识到你可能会使用不同的探索方法。
Atari 实验
MountainCar 环境是一个非常好的快速实验探索方法,但为了总结这一章,我包含了带有我们描述过的探索调整的 DQN 和 PPO 方法的 Atari 版本,以便检查一个更复杂的环境。
作为主要环境,我使用了 Seaquest,这是一个潜艇需要击败鱼类和敌人潜艇,并拯救水下宇航员的游戏。这个游戏没有像《蒙特祖玛的复仇》那么有名,但它仍然可以算作是中等难度的探索,因为要继续游戏,你需要控制氧气的水平。当氧气变低时,潜艇需要升到水面一段时间。如果没有这个操作,游戏将在 560 步后结束,且最大奖励为 20。然而,一旦智能体学会如何补充氧气,游戏几乎可以无限继续,并为智能体带来 10k-100k 的分数。令人惊讶的是,传统的探索方法在发现这一点时有困难;通常,训练会在 560 步时卡住,之后氧气耗尽,潜艇就会死掉。
Atari 的一个负面方面是每次实验至少需要半天的训练才能检查效果,因此我的代码和超参数距离最佳状态还有很大差距,但它们可能作为你自己实验的起点是有用的。当然,如果你发现了改进代码的方法,请在 GitHub 上分享你的发现。
和之前一样,有两个程序文件:atari_dqn.py,实现了带有𝜖-贪婪和噪声网络探索的 DQN 方法;atari_ppo.py,实现了 PPO 方法,带有可选的噪声网络和网络蒸馏方法。要在超参数之间切换,需要使用命令行选项-p。
在接下来的章节中,让我们看看我通过几次代码运行得到的结果。
DQN + 𝜖-贪婪
与其他在 Atari 上尝试过的方法相比,𝜖-贪婪表现最好,这可能会让人感到惊讶,因为它在本章前面的 MountainCar 实验中给出了最差的结果。但这在现实中是很常见的,并且可能会带来新的研究方向,甚至突破。经过 13 小时的训练,它能够达到 18 的平均奖励,最大奖励为 25。根据显示步骤数的图表,只有少数几个回合能够发现如何获取氧气,因此,或许经过更多的训练,这种方法可以突破 560 步的限制。在图 18.13 中,显示了平均奖励和步骤数的图表:
图 18.13:DQN 与𝜖-贪婪的平均训练奖励(左)和步骤数(右)
DQN + 噪声网络
带有噪声网络的 DQN 表现更差——经过 6 小时的训练,它的奖励值只能达到 6。在图 18.14 中,显示了相关的图表:
图 18.14:在带有噪声网络的 DQN 上的平均训练奖励(左)和步数(右)
PPO
PPO 实验的表现更差——所有的组合(原生 PPO、噪声网络和网络蒸馏)都没有奖励进展,平均奖励只能达到 4。这有点令人惊讶,因为在本书的上一版中,使用相同代码的实验能获得更好的结果。这可能表明代码或我使用的训练环境中存在一些细微的 bug。你可以自己尝试这些方法!
概述
在本章中,我们讨论了为什么𝜖-贪心探索在某些情况下不是最佳方法,并检查了现代的替代探索方法。探索的主题要广泛得多,还有很多有趣的方法未被涉及,但我希望你能够对这些新方法以及它们如何在自己的问题中实施和使用有一个整体的印象。
在下一章中,我们将探讨另一种在复杂环境中探索的方法:带有人工反馈的强化学习(RLHF)。
第十九章:通过人类反馈的强化学习
在本章中,我们将介绍一种相对较新的方法,解决了当期望的行为很难通过明确的奖励函数定义时的情况——通过人类反馈的强化学习(RLHF)。这也与探索相关(因为该方法允许人类推动学习朝着新的方向发展),这是我们在第十八章中讨论过的问题。令人惊讶的是,这种方法最初是为强化学习领域中的一个非常特定的子问题开发的,结果在大型语言模型(LLM)中取得了巨大的成功。如今,RLHF 已成为现代 LLM 训练流程的核心,没有它,近期的惊人进展是不可能实现的。
由于本书并不涉及 LLM 和现代聊天机器人,我们将纯粹聚焦于 OpenAI 和 Google 的 Christiano 等人所提出的原始论文《来自人类偏好的深度强化学习》[Chr+17],该论文描述了 RLHF 方法如何应用于强化学习问题和环境。但在方法概述中,我会简要解释这种方法是如何在 LLM 训练中使用的。
在本章中,我们将:
-
看看人类反馈在强化学习中的应用,以解决奖励目标不明确和探索的问题。
-
从零开始实现一个 RLHF 流程,并在 SeaQuest Atari 游戏中进行测试,以教会它新的行为。
复杂环境中的奖励函数
在深入讨论 RLHF 方法之前,让我们先讨论一下这一概念背后的动机。正如我们在第一章中讨论的,奖励是强化学习的核心概念。没有奖励,我们就像瞎子——我们已经讨论过的所有方法都严重依赖于环境提供的奖励值:
-
在基于价值的方法(本书第二部分)中,我们使用奖励来近似 Q 值,以评估行为并选择最优的行动。
-
在基于策略的方法(第三部分)中,奖励的使用更加直接——作为策略梯度的尺度。去掉所有数学内容后,我们基本上优化了我们的策略,以偏好那些能够带来更多累计未来奖励的行为。
-
在黑箱方法(第十七章)中,我们使用奖励来做出关于代理变体的决策:应该保留它们以供将来使用,还是丢弃?
在我们实验过的几乎所有强化学习环境中,奖励函数都是预定义的——在 Atari 游戏中,我们有得分;在 FrozenLake 环境中,它是一个明确的目标位置;在模拟机器人中,它是行进的距离,等等。唯一的例外是在第十章,我们自己实现了环境(股票交易系统),并且必须决定如何设计奖励。即便在那个例子中,应该使用什么作为奖励也相当明显。
不幸的是,在现实生活中,确定应作为奖励的内容并非总是那么简单。让我们来看几个例子。如果我们在训练聊天机器人解决一组任务时,除了确保任务正确完成外,还必须考虑完成任务的方式。如果我们问系统“明天的天气预报是什么?”它回答正确但语气粗鲁,应该因其不礼貌的回答而受到负面奖励吗?如果是相反的情况——回答非常礼貌,但信息错误呢?如果我们只优化一个标准(比如信息的正确性),我们可能会得到一个“能工作”的系统,但它在现实生活中却不可用——因为它太笨拙,没人愿意使用。
另一个“单一优化因素”的例子是从 A 点到 B 点的货物运输。运输公司并不仅仅通过一切手段最大化他们的利润。此外,他们还面临着大量的限制和规定,如驾驶规则、工作时间、劳动法规等。如果我们仅在系统中优化一个标准,最终可能会得到“穿越邻居的栅栏——这是最快的路。”因此,在现实生活中,追求单一的最大化标准是例外而非常态。大多数情况下,我们有多个参数共同作用于最终结果,我们需要在它们之间找到某种平衡。即使在我们之前见过的雅达利游戏中,分数也可能是不同“子目标”之和的结果。一个非常好的例子是我们在上一章实验过的《SeaQuest》游戏。如果你以前没玩过,可以在浏览器中进行体验,以更好地理解:www.retrogames.cz/play_221-Atari2600.php。
在这款游戏中,你控制潜艇,并根据以下活动获得分数:
-
射击邪恶的鱼类和敌方潜艇
-
救援潜水员并将他们带回水面
-
避免敌人火力和水面上的船只(它们出现在游戏的后期关卡)
由于氧气有限,潜艇必须定期上浮以补充氧气。大多数现代强化学习方法在发现射击鱼类和潜艇的奖励时没有问题——从试错开始,经过几小时的训练,智能体就能学会如何通过射击获得奖励。
但发现通过拯救潜水员来得分要困难得多,因为只有在收集了六个潜水员并成功到达水面后才会给予奖励。通过试错法发现氧气补充也很困难,因为我们的神经网络对氧气、潜水艇以及潜水艇突然死亡如何与屏幕底部的仪表相关联没有先验知识。我们的强化学习方法与𝜖-贪婪探索可以看作是一个刚出生的婴儿随机按按钮并因正确的动作序列而获得奖励,这可能需要很长时间才能执行正确的长序列。
结果是,在《SeaQuest》中的大多数训练回合都受到平均得分 300 和 500 游戏步骤的限制。潜水艇因缺氧而死,随机的表面访问过于稀少,以至于无法发现游戏可以玩得更久。同时,从未见过这个游戏的人能够在几分钟的游戏时间里找出如何补充氧气并拯救潜水员。
潜在地,我们可以通过将氧气纳入奖励函数(例如作为补充氧气的额外奖励)来帮助我们的智能体,并以某种方式解释氧气为何重要,但这可能会引发环境调整的恶性循环——正是我们通过使用强化学习方法所试图避免的那些努力。
如你所料,RLHF 正是能够让我们避免这种低级奖励函数调整的方法,使得人类能够对智能体的行为提供反馈。
理论背景
让我们来看一下 OpenAI 和 Google 研究人员在 2017 年发布的原始 RLHF 方法[Chr+17]。自从这篇论文发布后(尤其是在 ChatGPT 发布之后),该方法成为了一个活跃的研究领域。有关最新的进展,你可以查看github.com/opendilab/awesome-RLHF上的论文。此外,我们还将讨论 RLHF 在大语言模型(LLM)训练过程中的作用。
方法概述
论文的作者实验了两类问题:几种来自 MuJoCo 模拟机器人环境(类似于我们在第十五章和第十六章讨论的连续控制问题)和几种 Atari 游戏。
核心思想是保持原始的强化学习模型,但用一个神经网络替代来自环境的奖励,这个神经网络叫做奖励预测器,它是通过人类收集的数据进行训练的。这个网络(在论文中表示为 r̂(o,a))接受观察和动作,并返回该动作的即时奖励浮动值。
该奖励预测器的训练数据并非直接由人类提供,而是从人类偏好中推断出来:人们会看到两个短视频片段,其中展示了智能体的行为,并被问到“哪一个更好?”换句话说,奖励预测器的训练数据是两个情节片段 σ¹ 和 σ²(包含观察和动作的固定长度序列 (o[t],a[t])) 和来自人类的标签 μ,表示哪个片段更受偏好。给定的答案选项有“第一个”,“第二个”,“两个都好”和“无法判断”。
网络 r̂ (o,a) 是通过使用标签与函数 p̂[σ¹ ≻σ²] 之间的交叉熵损失来训练的,p̂[σ¹ ≻σ²] 是对人类偏好 σ¹ 相较于 σ² 的概率的估计:
换句话说,我们对片段中的每一步预测奖励进行求和,取每个奖励的指数,然后对总和进行归一化。交叉熵损失是使用二分类的标准公式计算的:
μ[1] 和 μ[2] 的值是根据人类的判断分配的。如果第一个片段比第二个片段更受偏好,则 μ[1] = 1,μ[2] = 0。若第二个片段更好,则 μ[2] = 1,μ[1] = 0。如果人类认为两个片段都很好,则两个 μ 都设置为 0.5。与其他方法相比,这种奖励模型有几个优点:
-
通过使用神经网络进行奖励预测,我们可以显著减少所需的标签数量。极端情况下,可能要求人类标注策略的每个动作,但在强化学习的情况下,这是不可行的,因为在环境中会有数百万次交互发生。在高层目标的情况下,这几乎是不可能完成的任务。
-
我们不仅给网络反馈好的行为,还给它反馈我们不喜欢的行为。如果你记得,在第十四章中,我们使用记录下来的人工示范来训练网络自动化代理。但人工示范只展示了正面例子(“做这个”),没有办法包含负面例子(“不要做那个”)。此外,人工示范更难收集,且可能包含更多的错误。
-
通过询问人类偏好,我们可以处理那些人类能够识别我们想要的行为,但不一定能复制的情况。例如,控制第十六章中的四足蚂蚁机器人对人类来说可能非常具有挑战性。同时,我们也没有检测出机器人行为正常或策略错误时的困难。
在 RLHF 论文中,作者实验了不同的奖励模型训练方法及其在强化学习训练过程中的使用。在他们的设置中,三种不同的过程同时运行:
-
使用的 RL 训练方法(A2C)使用当前的 r̂ (o, a) 网络进行奖励预测。随机轨迹段 σ = (o[i], a[i]) 被存储在标注数据库中。
-
人类标注者采样了一对段落(σ¹, σ²),并为其分配标签 μ,标签被存储在标注数据库中。
-
奖励模型 r̂ (o, a) 会定期在来自数据库的标注对上进行训练,并发送到 RL 训练过程中。
该过程如图 19.1 所示。
图 19.1: RLHF 结构
如前所述,本文讨论了两类问题:Atari 游戏和连续控制。在这两类问题上,结果并不特别显著——有时传统的 RL 比 RLHF 更好,有时则相反。但 RLHF 真正突出的地方是在大语言模型(LLM)的训练流程中。我们在开始 RLHF 实验之前,简要讨论一下为什么会发生这种情况。
RLHF 和 LLMs
ChatGPT 于 2022 年底发布,很快成为了一个大热话题。对于普通用户来说,它甚至比 2012 年的 AlexNet 还要有影响力,因为 AlexNet 是“技术性的东西”——它推动了边界,但很难解释它到底有多特别。ChatGPT 不一样:发布仅一个月后,它的用户数量就突破了 1 亿,而几乎每个人都在谈论它。
ChatGPT(以及任何现代 LLM)训练流程的核心是 RLHF。因此,这种微调大模型的方法迅速流行开来,并且在研究兴趣上也有所增长。由于这不是一本关于 LLM 的书,我将简要描述该流程以及 RLHF 是如何融入其中的,因为从我的角度来看,这是一个有趣的应用案例。
从高层次来看,LLM 训练由三个阶段组成:
-
预训练:在这里,我们在一个庞大的文本语料库上对语言模型进行初步训练。基本上,我们会尽可能获取所有的信息并进行无监督的语言模型训练。数据量(及其成本)是巨大的——用于 LLaMA 训练的 RedPajama 数据集包含 1.2 万亿个标记(大约相当于 1500 万本书)。
在这个阶段,我们的随机初始化模型学习语言的规律性和深层次的联系。但由于数据量庞大,我们不能仅仅挑选这些数据——它们可能是假新闻、仇恨言论帖子,或是你在互联网上随便可以找到的其他怪异内容。
-
监督微调:在这一步,我们会在预定义的精选示例对话上对模型进行微调。此处使用的数据集是手动创建并验证正确性的,数据量显著较小——大约为 10K-100K 个示例对话。
这些数据通常由该领域的专家创建,需要大量的精力来制作并进行复核。
-
RLHF 微调(也称为“模型对齐”):这一步使用了我们已经描述过的相同过程:生成的对话对呈现给用户进行标注,奖励模型基于这些标签进行训练,并在 RL 算法中使用这个奖励模型来微调 LLM 模型,使其遵循人类的偏好。标注样本的数量比监督微调步骤要多(大约 1M 对),但因为比较两个对话要比从头开始创建一个合适的对话简单得多,所以这不是问题。
正如你可能猜到的,第一步是最耗费资源和时间的:你必须处理大量的文本并通过变换器进行处理。但同时,这些步骤的重要性是完全不同的。在最后一步,系统不仅学习如何解决呈现的问题,还会得到生成问题答案时是否符合社会接受方式的反馈。
RLHF 方法非常适合这个任务——只需要一对对话,它就能学习代表标注者隐式“偏好模型”的奖励模型,应用于像聊天机器人这样复杂的事物。显式地做这件事(例如通过奖励函数)可能是一个具有很大不确定性的挑战性问题。
RLHF 实验
为了更好地理解我们刚才讨论的流程,让我们自己动手实现它(因为“做是最好的学习方法”)。在上一章中,我们尝试了 Atari SeaQuest 环境,从探索角度来看,这个环境有一定难度,因此利用这个环境并检查我们能通过人类反馈取得什么成就是合乎逻辑的。
为了限制本章的范围并使例子更具可复现性,我对 RLHF 论文 [Chr+17] 中描述的实验进行了以下修改:
-
我专注于单一的 SeaQuest 环境。目标是提高代理在与第十八章中 A2C 结果的对比中的游戏表现——平均得分为 400,回合步数为 500 步(由于缺氧)。
-
我将其从异步标注和奖励模型训练的过程,分成了单独的步骤:
-
执行了 A2C 训练,将轨迹段存储在本地文件中。此训练可选择性地加载并使用奖励模型网络,这使得我们可以在训练后迭代奖励模型,标记更多的样本。
-
Web UI 让我可以为随机的轨迹段对打上标签,并将标签存储在一个 JSON 文件中。
-
奖励模型在这些段落和标签上进行了训练。训练结果被存储在磁盘上。
-
-
我避免了所有与奖励模型训练相关的变体:没有 L2 正则化,没有集成方法等。
-
标签的数量显著减少:在每次实验中,我标记了额外的 100 对回合段,并重新训练了模型。
-
动作明确地加入了奖励模型中。详情请参阅“奖励模型”一节。
-
奖励模型在 A2C 训练中用于对保存的最佳模型进行微调。为了说明背景,在论文中,模型是从零开始训练的,并通过并行的 RLHF 标注和奖励模型重训练得到了改善。
使用 A2C 进行初始训练
为了获得第一个模型(我们称之为“版本 0”或简称 v0),我使用了标准的 A2C 代码,并配合本书前面已经多次讨论过的 Atari 包装器。
要开始训练,您需要运行 Chapter19/01_a2c.py 模块,除了基本的 A2C 训练外,它还包含一个命令行选项,用于启用奖励模型(我们在前面的章节中介绍过),但在此步骤中我们不需要它。
目前,要开始基本模型的训练,请使用以下命令行:
Chapter19$ ./01_a2c.py --dev cuda -n v0 --save save/v0 --db-path db-v0
A.L.E: Arcade Learning Environment (version 0.8.1+53f58b7)
[Powered by Stella]
AtariA2C(
(conv): Sequential(
(0): Conv2d(4, 32, kernel_size=(8, 8), stride=(4, 4))
(1): ReLU()
(2): Conv2d(32, 64, kernel_size=(4, 4), stride=(2, 2))
(3): ReLU()
(4): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1))
(5): ReLU()
(6): Flatten(start_dim=1, end_dim=-1)
)
(policy): Sequential(
(0): Linear(in_features=3136, out_features=512, bias=True)
(1): ReLU()
(2): Linear(in_features=512, out_features=18, bias=True)
)
(value): Sequential(
(0): Linear(in_features=3136, out_features=512, bias=True)
(1): ReLU()
(2): Linear(in_features=512, out_features=1, bias=True)
)
)
0: Testing model...
Got best reward 40.00 and steps 213.0 in 10 episodes
1024: done 1 games, mean reward 0.000, steps 70, speed 312.22 f/s
1056: done 2 games, mean reward 0.000, steps 72, speed 1188.69 f/s
1104: done 3 games, mean reward 0.000, steps 75, speed 1216.18 f/s
以下是命令行选项的描述:
-
--dev: 用于计算的设备名称。
-
-n: 运行的名称,用于 TensorBoard。
-
--save: 在测试后将存储最佳模型的目录名称。每训练 100 批次,我们会对当前模型在 SeaQuest 上进行 10 次测试剧集,禁用奖励剪切(以获取原始分数范围),如果这 10 轮中的最佳奖励或步骤数超过我们之前的记录,我们会将模型保存到文件中。这些文件稍后将用于微调。
-
--db-path: 在训练过程中将存储随机剧集片段的目录名称。这些数据稍后将用于奖励模型的标注和训练。
让我们讨论一下剧集片段数据库(简称 DB)。其结构非常简单:每个用于训练的环境(总共有 16 个)都有一个从 0 到 15 的标识符,这个标识符用作 --db-path 命令行参数所给定目录下的子目录。因此,每个环境都会在自己的目录中独立存储随机片段。存储逻辑是通过 Gym API Wrapper 子类实现的,这个子类叫做 EpisodeRecorderWrapper,位于 lib/rlhf.py 模块中。
让我们来看一下包装器的源代码。最初,我们声明了两个超参数,EPISODE_STEPS,它定义了片段的长度,以及 START_PROB,它表示开始剧集记录的概率:
# how many transitions to store in episode
EPISODE_STEPS = 50
# probability to start episode recording
START_PROB = 0.00005
@dataclass(frozen=True)
class EpisodeStep:
obs: np.ndarray
act: int
class EpisodeRecorderWrapper(gym.Wrapper):
def __init__(self, env: gym.Env, db_path: pathlib.Path, env_idx: int,
start_prob: float = START_PROB, steps_count: int = EPISODE_STEPS):
super().__init__(env)
self._store_path = db_path / f"{env_idx:02d}"
self._store_path.mkdir(parents=True, exist_ok=True)
self._start_prob = start_prob
self._steps_count = steps_count
self._is_storing = False
self._steps: tt.List[EpisodeStep] = []
self._prev_obs = None
self._step_idx = 0
我们将剧集片段存储为一系列 EpisodeStep 对象,这些对象只是我们在该步骤中所采取的观察和动作。重置环境的方法非常简单——它会更新包装器的 _step_idx 字段(这是我们在该环境中已执行步骤的计数器),并根据 _is_store 字段将观察值存储在 _prev_obs 字段中。如果 _is_store 字段为 True,则表示我们正在进行片段记录。
我们的片段有固定数量的环境步骤(默认为 50 步),它们独立于剧集边界进行记录(换句话说,如果我们在潜艇死亡前不久开始片段记录,那么在调用 reset() 方法后,我们会记录下一剧集的开始):
def reset(self, *, seed: int | None = None, options: dict[str, tt.Any] | None = None) \
-> tuple[WrapperObsType, dict[str, tt.Any]]:
self._step_idx += 1
res = super().reset(seed=seed, options=options)
if self._is_storing:
self._prev_obs = deepcopy(res[0])
return res
如果你愿意,你可以尝试这种逻辑,因为原则上,剧集结束后的观察数据与剧集结束前的观察和动作是独立的。但这样会使剧集片段数据的处理更复杂,因为数据长度将变得可变。
包装器的主要逻辑在 step()方法中,也不是很复杂。每次动作时,如果我们正在录制,就存储该步骤;否则,我们会生成一个随机数来决定是否开始录制:
def step(self, action: WrapperActType) -> tuple[
WrapperObsType, SupportsFloat, bool, bool, dict[str, tt.Any]
]:
self._step_idx += 1
obs, r, is_done, is_tr, extra = super().step(action)
if self._is_storing:
self._steps.append(EpisodeStep(self._prev_obs, int(action)))
self._prev_obs = deepcopy(obs)
if len(self._steps) >= self._steps_count:
store_segment(self._store_path, self._step_idx, self._steps)
self._is_storing = False
self._steps.clear()
elif random.random() <= self._start_prob:
# start recording
self._is_storing = True
self._prev_obs = deepcopy(obs)
return obs, r, is_done, is_tr, extra
默认情况下,开始录制的概率很小(START_PROB = 0.00005,即 0.005%的几率),但由于训练过程中我们进行的大量步骤,我们仍然有足够的片段可以标注。例如,在 1200 万环境步骤(约 5 小时的训练)之后,数据库中包含了 2,500 个录制的片段,占用了 12GB 的磁盘空间。
方法 step()使用函数 store_segment()存储 EpisodeStep 对象的列表,这实际上是对步骤列表的 pickle.dumps()调用:
def store_segment(root_path: pathlib.Path, step_idx: int, steps: tt.List[EpisodeStep]):
out_path = root_path / f"{step_idx:08d}.dat"
dat = pickle.dumps(steps)
out_path.write_bytes(dat)
print(f"Stored {out_path}")
在讨论训练结果之前,我需要提到一个关于包装器使用的小细节,虽然它不大,但很重要。为了让标注更容易,我们存储在数据库中的观察数据是来自标准 Atari 包装器之前的。这虽然增加了我们需要存储的数据量,但人工标注者将看到原始的、色彩丰富的 Atari 屏幕,分辨率为原始的 160 × 192,而不是降级后的灰度图像。
为了实现这一点,包装器在原始 Gymnasium 环境之后、Atari 包装器之前应用。以下是 01_a2c.py 模块中的相关代码片段:
def make_env() -> gym.Env:
e = gym.make("SeaquestNoFrameskip-v4")
if reward_path is not None:
p = pathlib.Path(reward_path)
e = rlhf.RewardModelWrapper(e, p, dev=dev, metrics_queue=metrics_queue)
if db_path is not None:
p = pathlib.Path(db_path)
p.mkdir(parents=True, exist_ok=True)
e = rlhf.EpisodeRecorderWrapper(e, p, env_idx=env_idx)
e = ptan.common.wrappers.wrap_dqn(e)
# add time limit after all wrappers
e = gym.wrappers.TimeLimit(e, TIME_LIMIT)
return e
训练过程的超参数来自论文(学习率下降计划、网络架构、环境数量等)。我让它训练了 5 小时,进行了 1200 万次观察。测试结果的图表显示在图 19.2 中。
图 19.2:A2C 训练过程中的奖励(左)和步骤(右)
最佳模型能够达到 460 的奖励水平(环境中没有奖励裁剪),虽然很不错,但与时不时补充氧气所能达到的结果相比要差得多。
该模型的游戏视频可以在youtu.be/R_H3pXu-7cw观看。正如你从视频中看到的,我们的智能体几乎完美地掌握了射击鱼类的技巧,但它在浮在底部的局部最优解上卡住了(可能因为在那里更安全,敌方潜艇不在那里),并且对氧气补充一无所知。
你可以使用工具 01_play.py 从模型文件录制自己的视频,输入模型文件名即可。
标注过程
在 A2C 训练过程中,我们获得了 12GB 的 2,500 个随机剧集片段。每个片段包含 50 个步骤,包含屏幕观察和智能体在每一步采取的动作。现在我们已经准备好进行 RLHF 管道的标注过程。
在标注过程中,我们需要随机抽取剧集片段对并展示给用户,询问“哪个更好?”。答案应存储用于奖励模型的训练。正是这个逻辑在 02_label_ui.py 中实现。
标注过程的 UI 作为一个 web 应用实现,使用了 NiceGUI 库(nicegui.io/)。NiceGUI 允许用 Python 实现现代 web 应用 UI,并提供了一套丰富的交互式 UI 控件,如按钮、列表、弹出对话框等。原则上,你不需要了解 JavaScript 和 CSS(但如果你熟悉它们也无妨)。如果你以前从未使用过 NiceGUI,也没问题;你只需在 Python 环境中通过以下命令安装它:
pip install nicegui==1.4.26
要启动标注 UI(在安装 NiceGUI 包之后),你需要指定存储剧集片段的数据库路径:
Chapter19$ ./02_label_ui.py -d db-v0
NiceGUI ready to go on http://localhost:8080, http://172.17.0.1:8080, http://172.18.0.1:8080, and http://192.168.10.8:8080
界面通过 HTTP 提供服务(所以,可以在浏览器中打开),并监听所有机器接口上的 8080 端口,这在你将其部署到远程服务器时非常方便(但你需要意识到可能的外部访问风险,因为标注 UI 完全没有身份验证和授权)。如果你想更改端口或将范围限制到特定的网络接口,只需修改 02_label_ui.py。让我们看一下标注界面的截图:
图 19.3:带有数据库信息的标注 UI 部分
这个界面非常基础:左侧有三个链接,指向 UI 功能的不同部分:
-
概览显示数据库路径、其中包含的片段总数以及已创建的标签数量。
-
标注新数据样本随机配对片段并允许你为其添加标签。
-
“现有标签”显示所有标签,并允许在需要时修改标签。
如有需要,可以通过点击左上角的按钮(带有三个横线)隐藏或显示包含链接的列表。最多的时间花费在“标注新数据”部分,见图 ??:
图 19.4:添加新标签的界面(为了更好地可视化,参考 packt.link/gbp/9781835…
这里我们有一个包含 20 对随机抽取的剧集片段的列表,可以进行标注。当列表中的条目被选择时,界面会显示这两段片段(作为代码实时生成的动画 GIF)。用户可以点击三个按钮中的一个来添加标签:
-
#1 更好(1):将第一个片段标记为首选。在奖励模型训练过程中,这样的条目会有 μ[1] = 1.0 和 μ[2] = 0.0。
-
两者都好(0):将两个片段标记为同样好(或差),赋值 μ[1] = 0.5 和 μ[2] = 0.5。
-
#2 更好(2):将第二个片段标记为首选(μ[1] = 0.0 和 μ[2] = 1.0)。
你可以通过使用键盘上的 0(“两者都好”)、1(“第一个更好”)或 2(“第二个更好”)来分配标签,而无需点击 UI 按钮。标签分配完成后,UI 会自动选择列表中的下一个未标记条目,这样整个标记过程仅使用键盘就能完成。当你完成列表中的所有标签后,可以点击 RESAMPLE LIST 按钮加载 20 个新的样本进行标记。
在每个标签被分配后(通过点击 UI 按钮或按下键盘键),这些标签会存储在 DB 目录根目录下的 JSON 文件 labels.json 中。该文件采用简单的 JSON 行格式,每行都是一个包含段落路径(相对于 DB 根目录)和已分配标签的条目:
Chapter19$ head db-v0/labels.json
{"sample1":"14/00023925.dat","sample2":"10/00606788.dat","label":0}
{"sample1":"02/01966114.dat","sample2":"10/01667833.dat","label":2}
{"sample1":"00/02432057.dat","sample2":"06/01410909.dat","label":1}
{"sample1":"01/02293138.dat","sample2":"11/00997214.dat","label":0}
{"sample1":"10/00091149.dat","sample2":"11/01262679.dat","label":2}
{"sample1":"12/01394239.dat","sample2":"04/01792088.dat","label":2}
{"sample1":"10/01390371.dat","sample2":"09/00077676.dat","label":0}
{"sample1":"10/01390371.dat","sample2":"09/00077676.dat","label":1}
{"sample1":"12/02339611.dat","sample2":"00/02755898.dat","label":2}
{"sample1":"06/00301623.dat","sample2":"06/00112361.dat","label":2}
如果需要,可以通过使用“现有标签”链接(如图 19.5 所示)来查看现有标签,该界面几乎与“标记新数据”相同,不同之处在于它显示的不是 20 个新采样的对,而是已经标记的对。这些对可以通过点击按钮或使用前面描述的键盘快捷键进行更改。
图 19.5:查看和编辑旧标签的界面(为了更好的可视化,参见 packt.link/gbp/9781835… )
在我的实验中,我进行了第一轮标记,共标记了 100 对样本,主要关注潜水艇出现在水面上的罕见情况(标记为好)和氧气不足时更为常见的情况(标记为坏)。在其他情况下,我更倾向于选择那些鱼群被正确击中的段落。有了这些标签,我们就可以进入下一步:奖励模型训练。
奖励模型训练
奖励模型网络大多数结构来自论文,唯一的不同在于如何处理动作。在论文中,作者没有明确说明如何考虑动作,只是提到“对于奖励预测器,我们使用 84 × 84 的图像作为输入(与策略的输入相同),并将 4 帧图像堆叠在一起,形成总共 84 × 84 × 4 的输入张量。”根据这一点,我假设奖励模型通过帧之间的动态“隐式”地扣除动作。我在实验中没有尝试这种方法,而是决定通过将 one-hot 编码与从卷积层获得的向量拼接在一起,显式地向网络展示动作。作为一个练习,你可以修改我的代码,使用论文中的方法并比较结果。其余的架构和训练参数与论文中的相同。接下来,让我们看一下奖励模型网络的代码:
class RewardModel(nn.Module):
def __init__(self, input_shape: tt.Tuple[int, ...], n_actions: int):
super().__init__()
self.conv = nn.Sequential(
nn.Conv2d(input_shape[0], 16, kernel_size=7, stride=3),
nn.BatchNorm2d(16),
nn.Dropout(p=0.5),
nn.LeakyReLU(),
nn.Conv2d(16, 16, kernel_size=5, stride=2),
nn.BatchNorm2d(16),
nn.Dropout(p=0.5),
nn.LeakyReLU(),
nn.Conv2d(16, 16, kernel_size=3, stride=1),
nn.BatchNorm2d(16),
nn.Dropout(p=0.5),
nn.LeakyReLU(),
nn.Conv2d(16, 16, kernel_size=3, stride=1),
nn.BatchNorm2d(16),
nn.Dropout(p=0.5),
nn.LeakyReLU(),
nn.Flatten(),
)
size = self.conv(torch.zeros(1, *input_shape)).size()[-1]
self.out = nn.Sequential(
nn.Linear(size + n_actions, 64),
nn.LeakyReLU(),
nn.Linear(64, 1),
)
def forward(self, obs: torch.ByteTensor, acts: torch.Tensor) -> torch.Tensor:
conv_out = self.conv(obs / 255)
comb = torch.hstack((conv_out, acts))
out = self.out(comb)
return out
正如你所看到的,卷积层与批量归一化、丢弃层和 leaky ReLU 激活函数结合使用。
奖励模型的训练在 03_reward_train.py 中实现,过程没有什么复杂的。我们从 JSON 文件中加载标注数据(你可以在命令行中传递多个数据库来用于训练),使用 20% 的数据进行测试,并计算二元交叉熵目标,这在 calc_loss() 函数中实现:
def calc_loss(model: rlhf.RewardModel, s1_obs: torch.ByteTensor,
s1_acts: torch.Tensor, s2_obs: torch.ByteTensor,
s2_acts: torch.Tensor, mu: torch.Tensor) -> torch.Tensor:
batch_size, steps = s1_obs.size()[:2]
s1_obs_flat = s1_obs.flatten(0, 1)
s1_acts_flat = s1_acts.flatten(0, 1)
r1_flat = model(s1_obs_flat, s1_acts_flat)
r1 = r1_flat.view((batch_size, steps))
R1 = torch.sum(r1, 1)
s2_obs_flat = s2_obs.flatten(0, 1)
s2_acts_flat = s2_acts.flatten(0, 1)
r2_flat = model(s2_obs_flat, s2_acts_flat)
r2 = r2_flat.view((batch_size, steps))
R2 = torch.sum(r2, 1)
R = torch.hstack((R1.unsqueeze(-1), R2.unsqueeze(-1)))
loss_t = F.binary_cross_entropy_with_logits(R, mu)
return loss_t
最初,我们的观察和动作张量具有以下结构:观察为(batch,time,colors,height,width),动作为(batch,time,actions),其中 time 是序列的时间维度。更具体地说,观察张量的大小为 64 × 50 × 3 × 210 × 160,动作的大小为 64 × 50 × 18。
作为损失计算的第一步,我们展平前两个维度,去除时间维度,并应用模型计算奖励值 r̂(o,a)。之后,我们恢复时间维度,并根据我们已经讨论过的论文公式沿时间维度求和。然后,我们的损失计算是应用 torch 函数来计算二元交叉熵。
在每个训练周期中,我们计算测试损失(基于 20% 的数据),并在新损失低于先前测试损失的最小值时保存奖励模型。如果训练损失连续四个周期增长,我们将停止训练。
在前一节中设置的标签数量(几百个)下,训练非常快速——大约十几个周期和几分钟时间。以下是示例训练过程。命令行参数 -o 指定保存最佳模型的目录名称:
Chapter19$ ./03_reward_train.py --dev cuda -n v0-rw -o rw db-v0
Namespace(dev=’cuda’, name=v0-rw’, out=’rw’, dbs=[’db-v0’])
Loaded DB from db-v0 with 149 labels and 2534 paths
RewardModel(
(conv): Sequential(
(0): Conv2d(3, 16, kernel_size=(7, 7), stride=(3, 3))
(1): BatchNorm2d(16, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(2): Dropout(p=0.5, inplace=False)
(3): LeakyReLU(negative_slope=0.01)
(4): Conv2d(16, 16, kernel_size=(5, 5), stride=(2, 2))
(5): BatchNorm2d(16, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(6): Dropout(p=0.5, inplace=False)
(7): LeakyReLU(negative_slope=0.01)
(8): Conv2d(16, 16, kernel_size=(3, 3), stride=(1, 1))
(9): BatchNorm2d(16, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(10): Dropout(p=0.5, inplace=False)
(11): LeakyReLU(negative_slope=0.01)
(12): Conv2d(16, 16, kernel_size=(3, 3), stride=(1, 1))
(13): BatchNorm2d(16, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(14): Dropout(p=0.5, inplace=False)
(15): LeakyReLU(negative_slope=0.01)
(16): Flatten(start_dim=1, end_dim=-1)
)
(out): Sequential(
(0): Linear(in_features=8978, out_features=64, bias=True)
(1): LeakyReLU(negative_slope=0.01)
(2): Linear(in_features=64, out_features=1, bias=True)
)
)
Epoch 0 done, train loss 0.131852, test loss 0.132976
Save model for 0.13298 test loss
Epoch 1 done, train loss 0.104426, test loss 0.354560
Epoch 2 done, train loss 0.159513, test loss 0.170160
Epoch 3 done, train loss 0.054362, test loss 0.066557
Save model for 0.06656 test loss
Epoch 4 done, train loss 0.046695, test loss 0.121662
Epoch 5 done, train loss 0.055446, test loss 0.064895
Save model for 0.06490 test loss
Epoch 6 done, train loss 0.024505, test loss 0.025308
Save model for 0.02531 test loss
Epoch 7 done, train loss 0.015864, test loss 0.045814
Epoch 8 done, train loss 0.024745, test loss 0.054631
Epoch 9 done, train loss 0.027670, test loss 0.054107
Epoch 10 done, train loss 0.025979, test loss 0.048673
Best test loss was less than current for 4 epoches, stop
将 A2C 与奖励模型相结合
一旦奖励模型训练完成,我们最终可以尝试将其用于 RL 训练。为此,我们使用相同的工具 01_a2c.py,但提供几个额外的参数:
-
-r 或 --reward:这是奖励模型的路径,用于加载和使用。通过此选项,我们不使用环境奖励,而是使用模型从我们决定采取的观察和动作中获得奖励。这作为额外的环境包装器实现;我们稍后会详细介绍。
-
-m 或 --model:这是要加载的演员模型的路径(存储在先前 A2C 训练轮次中)。由于我正在使用 RLHF 进行微调,而不是从头开始使用奖励模型训练,因此需要演员模型。原则上,你可以尝试使用奖励模型从零开始训练,但我的实验结果并不十分成功。
-
--finetune:启用微调模式:卷积层被冻结,学习率降低 10 倍。没有这些修改,演员很快就会忘记所有先前的知识,奖励几乎降到零。
因此,要使用我们刚刚训练的奖励模型,命令行看起来像这样:
./01_a2c.py --dev cuda -n v1 -r rw/reward-v0.dat --save save/v1 -m save/v0/model_rw=460-steps=580.dat --finetune
在检查实验结果之前,让我们看看奖励模型如何在 RL 训练过程中使用。为了最小化所需的改动,我实现了一个环境包装器,它被添加在原始环境和 Atari 包装器之间,因为奖励模型需要一个未经缩放的全彩游戏图像。
包装器的代码在 lib/rlhf.py 中,名为 RewardModelWrapper。包装器的构造函数从数据文件中加载模型并分配一些字段。根据论文,奖励模型预测的奖励经过标准化,使其均值为零,方差为一。因此,为了进行标准化,包装器维护了最后 100 个奖励值,使用 collections.deque。此外,包装器还可以有一个队列,用于发送指标。该指标包含关于标准化值和来自底层环境的真实总和的信息:
class RewardModelWrapper(gym.Wrapper):
KEY_REAL_REWARD_SUM = "real_reward_sum"
KEY_REWARD_MU = "reward_mu"
KEY_REWARD_STD = "reward_std"
def __init__(self, env: gym.Env, model_path: pathlib.Path, dev: torch.device,
reward_window: int = 100, metrics_queue: tt.Optional[queue.Queue] = None):
super().__init__(env)
self.device = dev
assert isinstance(env.action_space, gym.spaces.Discrete)
s = env.observation_space.shape
self.total_actions = env.action_space.n
self.model = RewardModel(
input_shape=(s[2], s[0], s[1]), n_actions=self.total_actions)
self.model.load_state_dict(torch.load(model_path, map_location=torch.device(’cpu’),
weights_only=True))
self.model.eval()
self.model.to(dev)
self._prev_obs = None
self._reward_window = collections.deque(maxlen=reward_window)
self._real_reward_sum = 0.0
self._metrics_queue = metrics_queue
在 reset()方法中,我们只需要记住观察并重置奖励计数器:
def reset(self, *, seed: int | None = None, options: dict[str, tt.Any] | None = None) \
-> tuple[WrapperObsType, dict[str, tt.Any]]:
res = super().reset(seed=seed, options=options)
self._prev_obs = deepcopy(res[0])
self._real_reward_sum = 0.0
return res
包装器的主要逻辑在 step()函数中,但并不复杂:我们将模型应用于观察和动作,标准化奖励,并返回它,而不是返回真实的奖励。从性能角度来看,模型应用效率不是很高,可能需要优化(因为我们有多个环境并行运行),但我决定先实现简单版本,把优化留给你作为练习:
def step(self, action: WrapperActType) -> tuple[
WrapperObsType, SupportsFloat, bool, bool, dict[str, tt.Any]
]:
obs, r, is_done, is_tr, extra = super().step(action)
self._real_reward_sum += r
p_obs = np.moveaxis(self._prev_obs, (2, ), (0, ))
p_obs_t = torch.as_tensor(p_obs).to(self.device)
p_obs_t.unsqueeze_(0)
act = np.eye(self.total_actions)[[action]]
act_t = torch.as_tensor(act, dtype=torch.float32).to(self.device)
new_r_t = self.model(p_obs_t, act_t)
new_r = float(new_r_t.item())
# track reward for normalization
self._reward_window.append(new_r)
if len(self._reward_window) == self._reward_window.maxlen:
mu = np.mean(self._reward_window)
std = np.std(self._reward_window)
new_r -= mu
new_r /= std
self._metrics_queue.put((self.KEY_REWARD_MU, mu))
self._metrics_queue.put((self.KEY_REWARD_STD, std))
if is_done or is_tr:
self._metrics_queue.put((self.KEY_REAL_REWARD_SUM, self._real_reward_sum))
self._prev_obs = deepcopy(obs)
return obs, new_r, is_done, is_tr, extra
剩下的训练部分相同。我们只需在环境创建函数中注入新的包装器(如果命令行中给定了奖励模型文件):
def make_env() -> gym.Env:
e = gym.make("SeaquestNoFrameskip-v4")
if reward_path is not None:
p = pathlib.Path(reward_path)
e = rlhf.RewardModelWrapper(e, p, dev=dev, metrics_queue=metrics_queue)
if db_path is not None:
p = pathlib.Path(db_path)
p.mkdir(parents=True, exist_ok=True)
e = rlhf.EpisodeRecorderWrapper(e, p, env_idx=env_idx)
e = ptan.common.wrappers.wrap_dqn(e)
# add time limit after all wrappers
e = gym.wrappers.TimeLimit(e, TIME_LIMIT)
return e
使用这段代码,我们现在可以将之前的模型与之前制作的标签结合起来。
使用 100 个标签进行微调
我使用从基本 A2C 训练中得到的最佳模型进行了训练,在测试中,该模型在 580 步内获得了 460 的奖励。此外,我启用了将回合片段采样到新 DB 目录(此处为 v1)的功能,因此完整的命令行如下:
./01_a2c.py --dev cuda -n v1 -r rw/reward-v0.dat --save save/v1 -m save/v0/model_rw=460-steps=580.dat --finetune --db-path v1 该模型很快就开始过拟合,在 2M 步(3 小时)后,我停止了训练。图 19.6 显示了测试结果(奖励和步骤数):
图 19.6:微调过程中测试奖励(左)和步骤(右)
图 19.7 显示了训练奖励(由模型预测)和总损失:
图 19.7:微调过程中训练奖励(左)和总损失(右)
最佳模型保存在 500K 训练步时,它能够在 1,120 步内获得 900 的奖励。与原始模型相比,这是一个相当大的改进。
该模型的视频记录可以在这里查看:youtu.be/LnPwuyVrj9g。从游戏玩法来看,我们看到代理学会了如何补充氧气,并且现在在屏幕中央停留了一段时间。我也有印象它更有意地选择了潜水员(但我并没有为这种行为做具体标注)。总体来说,这个方法有效,并且仅凭 100 个标签就能教会代理一些新东西,真的很令人印象深刻。
让我们通过更多的标注进一步改进模型。
第二轮实验
在第二轮实验中,我做了更多的标注:50 对来自 v0 数据库,50 对来自微调过程中存储的片段(v1 数据库)。在微调过程中生成的数据库(v1)包含了更多的潜艇漂浮在水面的片段,这证明我们的管道运行正常。在标注时,我也更加重视氧气补充的片段。
标注后,我重新训练了奖励模型,这只用了几分钟。然后,使用奖励模型对最佳 v1 模型(奖励为 900,步数为 1,120)进行了微调。
图 19.8 和图 19.9 包含了测试结果的图表、奖励训练和损失:
图 19.8:微调过程中的测试奖励(左)和步数(右)
图 19.9:微调过程中的训练奖励(左)和总损失(右)
在 1.5M 步(2 小时)之后,训练停滞了,但最佳模型并不比 v1 的最佳模型更好:最佳模型在 1,084 步中获得了 860 的奖励。
第三轮实验
在这里,我在标注时更加注意,不仅优先考虑氧气补充,还考虑了更好的鱼类射击和潜水员接取。不幸的是,100 对标签中只出现了几个潜水员的例子,因此需要更多的标注来教会代理这种行为。
关于潜水员,代理可能没有接取他们,因为潜水员与背景非常难以区分,在灰度图像中是不可见的。为了解决这个问题,我们可以调整 Atari 包装器中的对比度。
在奖励模型重新训练后,开始了 A2C 的微调。我也运行了大约 2M 步,持续了 3 小时,结果很有趣。在训练结束时(查看图 19.10 和图 19.11),测试中的船只达到了 5,000 步(这是我在环境中设定的限制),但得分相对较低。很可能,潜艇只是停留在水面上,这是非常安全的,但这不是我们想要的——这可能是由于标注样本的原因。奇怪的是,当我尝试录制这些后期模型的视频时,它们的行为发生了变化,步数也明显较低,这可能是测试中的某个 bug。
图 19.10:微调过程中的测试奖励(左)和步骤数(右)
图 19.11:微调过程中训练奖励(左)和总损失(右)
在过拟合之前,训练生成了几种比 v2 模型更好的策略。例如,在这个录音中,代理进行了两次氧气补充,并在 1,613 步中获得了 1,820 分:youtu.be/DVe_9b3gdxU。
总体结果
在下表中,我总结了实验回合的相关信息和我们得到的结果。
| 步骤 | 标签 | 奖励 | 步骤数 | 视频 |
|---|---|---|---|---|
| 初始 | 无 | 460 | 580 | youtu.be/R_H3pXu-7cw |
| v1 | 100 | 900 | 1120 | youtu.be/LnPwuyVrj9g |
| v2 | 200 | 860 | 1083 | |
| v3 | 300 | 1820 | 1613 | youtu.be/DVe_9b3gdxU |
表 19.1:实验回合总结
正如你所看到的,凭借仅仅 300 个标签,我们成功将分数提高了近 4 倍。作为一个练习,你可以尝试教代理捡起潜水员,如果做得好,可能会得到更好的成绩。
另一个可能值得尝试的实验是微调原始 v0 模型,而不是前一步中的最佳模型。这可能会导致更好的结果,因为训练在过拟合之前有更多时间。
总结
在本章中,我们了解了 RLHF 在 RL 工具箱中的新加入。这种方法是 LLM 训练流程的核心,可以提高模型的质量。在本章中,我们实现了 RLHF,并将其应用于 SeaQuest Atari 游戏,这应该向你展示了这种方法如何在 RL 流水线中用于模型改进。
在下一章中,我们将讨论另一类 RL 方法:AlphaGo、AlphaZero 和 MuZero。
第二十章:AlphaGo Zero 和 MuZero
基于模型的方法通过建立环境模型并在训练过程中使用它,帮助我们减少与环境的通信量。在本章中,我们通过探讨在我们拥有环境模型,但这个环境被两个竞争方使用的情况,来了解基于模型的方法。这种情况在棋类游戏中非常常见,游戏规则固定且整个局面可观察,但我们有一个对手,其主要目标是阻止我们赢得比赛。
几年前,DeepMind 提出了一个非常优雅的解决此类问题的方法。该方法不需要任何先前的领域知识,且智能体仅通过自我对弈来改善其策略。这个方法被称为 AlphaGo Zero,并在 2017 年推出。随后,在 2020 年,他们通过去除对环境模型的要求,扩展了该方法,使其能够应用于更广泛的强化学习问题(包括 Atari 游戏)。这个方法叫做 MuZero,我们也将详细探讨它。正如你将在本章中看到的,MuZero 比 AlphaGo Zero 更通用,但也伴随着更多需要训练的网络,可能导致更长的训练时间和更差的结果。从这个角度来看,我们将详细讨论这两种方法,因为在某些情况下,AlphaGo Zero 可能更具应用价值。
在本章中,我们将:
-
讨论 AlphaGo Zero 方法的结构
-
实现连接 4(Connect 4)游戏的玩法方法
-
实现 MuZero 并与 AlphaGo Zero 进行比较
比较基于模型和非基于模型的方法
在第四章中,我们看到了几种不同的方式来分类强化学习方法。我们区分了三大类:
-
基于价值和基于策略
-
基于策略和离策略
-
非基于模型和基于模型
到目前为止,我们已经涵盖了第一类和第二类中方法的足够示例,但我们迄今为止讨论的所有方法都是 100% 非基于模型的。然而,这并不意味着非基于模型的方法比基于模型的方法更重要或更好。从历史上看,由于样本效率高,基于模型的方法一直被应用于机器人领域和其他工业控制中。这也部分是因为硬件的成本以及从真实机器人中获得的样本的物理限制。具有较大自由度的机器人并不容易获得,因此强化学习研究者更专注于计算机游戏和其他样本相对便宜的环境。然而,机器人学的理念正渗透到强化学习中,因此,谁知道呢,也许基于模型的方法很快会成为关注的重点。首先,让我们讨论一下我们在本书中使用的非基于模型方法与基于模型方法的区别,包括它们的优缺点以及它们可能的应用场景。
在这两种方法的名称中,“模型”指的是环境的模型,它可以有多种形式,例如,通过当前状态和动作为我们提供新的状态和奖励。迄今为止涵盖的所有方法都没有做出任何努力去预测、理解或模拟环境。我们感兴趣的是正确的行为(以最终奖励为准),无论是直接指定(策略)还是间接指定(价值),这些都是基于观察得出的。观察和奖励的来源是环境本身,在某些情况下可能非常缓慢和低效。
在基于模型的方法中,我们试图学习环境模型,以减少对“真实环境”的依赖。总体而言,模型是一种黑箱,近似我们在第一章中讨论过的真实环境。如果我们有一个准确的环境模型,我们的智能体可以通过使用这个模型,而非在现实世界中执行动作,轻松地产生它所需的任何轨迹。
在某种程度上,强化学习研究的常见试验场也是现实世界的模型;例如,MuJoCo 和 PyBullet 是物理模拟器,用来避免我们需要构建拥有真实驱动器、传感器和摄像头的真实机器人来训练我们的智能体。这个故事在 Atari 游戏或 TORCS(开放赛车模拟器)中也是一样:我们使用模拟某些过程的计算机程序,这些模型可以快速而廉价地执行。即使是我们的 CartPole 例子,也是对一个附有杆的真实小车的简化近似。(顺便提一句,在 PyBullet 和 MuJoCo 中,还有更真实的 CartPole 版本,具备 3D 动作和更精确的模拟。)
使用基于模型的方法而非无模型方法有两个动机:
-
第一个也是最重要的原因是样本效率,源于对真实环境的依赖较少。理想情况下,通过拥有一个精确的模型,我们可以避免接触真实世界,仅使用训练好的模型。在实际应用中,几乎不可能拥有一个精确的环境模型,但即便是一个不完美的模型,也能显著减少所需样本的数量。
例如,在现实生活中,你不需要对某个动作(如系鞋带或过马路)有绝对精确的心理图像,但这个图像有助于你进行规划和预测结果。
-
基于模型方法的第二个原因是环境模型在不同目标之间的可转移性。如果你拥有一个优秀的机器人操控臂模型,你可以在不重新训练的情况下,利用它完成各种不同的目标。
这类方法有很多细节,但本章的目的是为你提供一个概览,并更深入地探讨应用于棋盘游戏的基于模型的方法。
基于模型的方法在棋盘游戏中的应用
大多数棋类游戏提供了与街机场景不同的设定。Atari 游戏系列假设一个玩家在某个环境中做决策,该环境具有复杂的动态。通过从他们的行动结果中进行泛化和学习,玩家能够提升技能,增加最终得分。然而,在棋类游戏的设定中,游戏规则通常非常简单和紧凑。使游戏复杂的因素是棋盘上不同位置的数量,以及存在一个对手,他有着未知的策略,试图赢得比赛。
对于棋类游戏,观察游戏状态的能力和明确规则的存在使得分析当前局势成为可能,这在 Atari 游戏中并不适用。这种分析意味着我们需要获取当前的游戏状态,评估我们可以进行的所有可能动作,然后选择最好的行动作为我们的动作。为了能够评估所有可能的动作,我们需要某种游戏模型来捕捉游戏规则。
评估的最简单方法是遍历所有可能的动作,并在执行动作后递归地评估该位置。最终,这个过程将引导我们到达最终位置,届时将不再有可能的移动。通过将游戏结果反向传播,我们可以估算任何位置上任何动作的预期值。这种方法的一种变体叫做极小极大法(minimax),它的核心是在我们试图做出最强的移动时,对手则试图为我们做出最坏的移动,因此我们在游戏状态树中迭代地最小化和最大化最终的游戏目标(该过程将在后面详细描述)。
如果不同位置的数量足够小,可以完全分析,比如井字游戏(只有 138 个终局状态),那么从我们当前拥有的任何状态出发,遍历这个游戏树并找出最佳行动并不成问题。不幸的是,这种暴力破解方法即使对于中等复杂度的游戏也不可行,因为配置数量呈指数增长。例如,在跳棋游戏中,整个游戏树有 5 ⋅ 10²⁰ 个节点,这对于现代硬件来说是一个相当大的挑战。在更复杂的游戏中,比如国际象棋或围棋,这个数字更大,因此完全分析从每个状态可以到达的所有位置几乎是不可能的。为了解决这个问题,通常会使用某种近似方法,在某个深度上分析游戏树。通过结合精心的搜索和停止标准(称为树剪枝)以及智能的预定义位置评估,我们可以制作一个能在相当高水平上进行复杂游戏的计算机程序。
AlphaGo Zero 方法
2017 年底,DeepMind 在《自然》杂志上发表了由 Silver 等人撰写的文章《无须人类知识的围棋游戏掌握》[SSa17],介绍了一种名为 AlphaGo Zero 的新方法,该方法能够在没有任何先验知识(除了规则)的情况下,达到超越人类的水平来玩复杂的游戏,如围棋和国际象棋。该代理能够通过不断自我对弈并反思结果来改进其策略。不需要大型的游戏数据库、手工特征或预训练的模型。该方法的另一个优点是其简洁性和优雅性。
在本章的示例中,我们将尝试理解并实现这种方法,应用于游戏“连接四”(也叫“四连棋”或“直线四”),以便自行评估其效果。
首先,我们将讨论该方法的结构。整个系统包含几个部分,在我们实现它们之前,需要先理解这些部分。
概述
从高层次来看,该方法由三个组件组成,所有这些将在后面详细解释,因此如果这一部分没有完全清楚,不必担心:
-
我们不断地使用蒙特卡罗树搜索(MCTS)算法遍历游戏树,其核心思想是半随机地走过游戏状态,扩展它们并收集关于每一步动作和潜在游戏结果的统计数据。由于游戏树庞大,深度和宽度都非常大,我们并不尝试构建完整的树,而是随机抽样其最有前景的路径(这就是该方法名称的来源)。
-
在每一时刻,我们都有当前最强的玩家,这是通过自我对弈生成数据的模型(这一概念将在后面详细讨论,但现在你只需要知道它指的是同一个模型与自己对弈)。最初,这个模型具有随机的权重,因此它的行动是随机的,就像一个四岁的孩子在学习棋子如何移动。然而,随着时间的推移,我们用它的更好变种替换这个最强的玩家,生成越来越有意义和复杂的游戏场景。自我对弈意味着同一个当前最强的模型在棋盘的两边同时使用。这看起来可能没什么用处,因为让同一个模型与自己对弈的结果大约是 50%的概率,但实际上这正是我们所需要的:我们的最佳模型能够展示其最佳技能的游戏样本。这个类比很简单:通常看外围选手与领头选手的比赛并不特别有趣;领头选手会轻松获胜。更有趣、更吸引人的情景是大致相等技能的选手对抗。因此,任何锦标赛的决赛总是比之前的比赛更受关注:决赛中的两个队伍或选手通常都擅长比赛,因此他们需要发挥出最佳水平才能获胜。
-
方法中的第三个组成部分是学徒模型的训练过程,该模型是在最佳模型通过自我对弈所收集的数据上训练的。这个模型可以比作一个孩子,坐在旁边不断分析两位成年人下的棋局。定期地,我们会进行几场这位训练模型与我们当前最佳模型的比赛。当学徒能够在大多数游戏中击败最佳模型时,我们宣布该训练模型为新的最佳模型,然后继续这一过程。
尽管这看起来简单甚至有些天真,AlphaGo Zero 仍然能够击败所有之前的 AlphaGo 版本,成为世界上最强的围棋玩家,且没有任何先验知识,只有规则。Silver 等人发布的论文[SSa17]之后,DeepMind 将该方法适应于国际象棋,并发布了名为《通过自我对弈和通用强化学习算法掌握国际象棋和将棋》的论文[Sil+17],其中从零开始训练的模型击败了当时最强的国际象棋程序 Stockfish,而 Stockfish 是经过十多年人类专家开发的。
现在,让我们详细了解该方法的三个组成部分。
MCTS
为了理解 MCTS 的工作原理,我们来考虑井字游戏的一个简单子树,如图 20.1 所示。开始时,游戏场地为空,交叉玩家(X)需要选择一个位置。第一次移动有九个不同的选择,所以我们的根节点有九个不同的分支,指向相应的状态。
图 20.1:井字游戏的游戏树
在任何游戏状态下的可选动作数量称为分支因子,它展示了游戏树的分支密度。一般来说,这个值不是常数,可能会有所变化,因为并不是所有的动作都是可行的。在井字游戏的情况下,可用的动作数量可能从游戏开始时的九个变化到叶节点时的零个。分支因子可以帮助我们估计游戏树的增长速度,因为每一个可用动作都会导致下一层的可执行动作。
对于我们的例子,在交叉玩家(X)走完一步后,零(0)在每个九个位置上有八个选择,这使得在树的第二层共有 9 × 8 个位置。树中节点的总数最多可达 9! = 362880,但实际数量较少,因为并非所有的游戏都会走到最大深度。
井字游戏虽然很简单,但如果我们考虑更复杂的游戏,比如思考一下在国际象棋游戏开始时白方的第一步可以走的数量(是 20),或者在围棋中白方棋子可以放置的位置数量(19 × 19 棋盘上总共有 361 个位置),整个树中游戏位置的数量迅速变得庞大。每新增一层,状态数量就会被上一层的平均动作数所乘。
为了应对这种组合爆炸,随机采样开始发挥作用。在一般的 MCTS 中,我们进行多次深度优先搜索,从当前游戏状态开始,随机选择动作或使用某些策略,策略中应该包含足够的随机性。每次搜索都继续进行,直到达到游戏的结束状态,然后根据游戏的结果更新访问过的树分支的权重。这个过程类似于值迭代方法,当我们玩过回合后,回合的最后一步会影响所有前面步骤的值估计。这是一个通用的 MCTS 方法,还有许多与扩展策略、分支选择策略及其他细节相关的变种。在 AlphaGo Zero 中,使用的是 MCTS 的变种。对于每个边(表示从某个位置的走法),这组统计信息被存储:
-
边的先验概率,P(s,a)
-
一个访问计数,N(s,a)
-
一个动作值,Q(s,a)
每次搜索从根状态开始,沿着最有前途的动作前进,这些动作是根据效用值 U(s,a)选择的,效用值与
在选择过程中加入随机性,以确保足够探索游戏树。每次搜索可能会有两种结果:游戏的最终状态被达成,或者我们遇到一个尚未探索的状态(换句话说,尚未有已知的值)。在后一种情况下,策略神经网络(NN)用于获得先验概率和状态估计值,然后创建一个新的树节点,其中 N(s,a) ← 0,P(s,a) ← p[net](这是网络返回的走法概率),且 Q(s,a) ← 0。除了动作的先验概率外,网络还返回游戏结果的估计值(或从当前玩家视角来看状态的价值)。
一旦我们获得了该值(通过达到最终游戏状态或通过使用神经网络扩展节点),会执行一个叫做值备份的过程。在此过程中,我们遍历游戏路径并更新每个访问过的中间节点的统计信息;特别地,访问计数 N(s,a)会增加 1,Q(s,a)会更新为当前状态下游戏结果的值。由于两个玩家交替进行操作,最终的游戏结果会在每次备份步骤中改变符号。
这个搜索过程会进行多次(在 AlphaGo Zero 中,进行 1,000 到 2,000 次搜索),收集足够的关于动作的统计信息,以便在根节点使用 N(s,a)计数器作为选择的动作概率。
自对弈
在 AlphaGo Zero 中,神经网络用于近似动作的先验概率并评估位置,这与优势演员-评论员(A2C)双头设置非常相似。在网络的输入中,我们传入当前的游戏位置(并加入若干个先前的局面),并返回两个值:
-
策略头返回行动的概率分布。
-
值头估算从玩家视角看待的游戏结果。这个值是未折扣的,因为围棋中的每一步都是确定性的。当然,如果在某个游戏中存在随机性,比如在西洋双陆棋中,就应该使用折扣。
如前所述,我们保持当前最优网络,该网络不断进行自对弈以收集用于训练学徒网络的数据。每一步自对弈游戏都从当前状态开始,执行多次蒙特卡洛树搜索(MCTS),以收集足够的游戏子树统计信息来选择最佳行动。选择依赖于当前的棋步和设置。对于自对弈游戏,为了在训练数据中产生足够的方差,初始几步的选择是随机的。然而,在经过一定数量的步骤后(这也是方法中的超参数),行动选择变得确定性,并且我们选择访问计数最大的行动,N(s,a)。在评估游戏中(当我们对训练中的网络与当前最优模型进行对比时),所有步骤都是确定性的,仅根据最大访问计数来选择行动。
一旦自对弈游戏结束并且最终结果已知,游戏的每一步都会被加入到训练数据集中,数据集是一个元组列表(s[t],π[t],r[t]),其中 s[t]是游戏状态,π[t]是通过 MCTS 采样计算的行动概率,r[t]是玩家在步骤 t 时的游戏结果。
训练与评估
当前最优网络的两个克隆之间的自对弈过程为我们提供了一系列训练数据,这些数据包含了通过自对弈游戏获得的状态、行动概率和位置值。凭借这些数据,在训练过程中,我们从重放缓冲区中抽取小批量的训练样本,并最小化值头预测与实际位置值之间的均方误差(MSE),以及预测概率与采样概率之间的交叉熵损失,π。
如前所述,在几个训练步骤后,训练好的网络会被评估,这包括当前最优网络与训练网络之间的多轮对弈。一旦训练网络的表现显著优于当前最优网络,我们将训练网络复制到最优网络中,并继续该过程。
用 AlphaGo Zero 玩“连接四”
为了观察该方法的实际应用,我们可以为一个相对简单的游戏——Connect 4 实现 AlphaGo Zero。这个游戏是两人对战,棋盘大小为 6 × 7。每位玩家有不同颜色的棋盘,轮流将棋子放入七列中的任何一列。棋子会下落到最底部,垂直堆叠。游戏的目标是率先形成一条水平、垂直或对角线,由四个相同颜色的棋子组成。为了展示游戏,图 20.2 中显示了两个位置。在第一种情况下,第一位玩家刚刚获胜,而在第二种情况下,第二位玩家即将形成一组。
图 20.2:Connect 4 中的两个游戏位置
尽管游戏简单,但它大约有 4.5 × 10¹² 种不同的游戏状态,这对于计算机来说,使用暴力破解是具有挑战性的。这个示例由几个工具和库模块组成:
-
Chapter20/lib/game.py:一个低级别的游戏表示,包含用于执行移动、编码和解码游戏状态以及其他与游戏相关的功能。
-
Chapter20/lib/mcts.py:MCTS 实现,支持 GPU 加速扩展叶节点和节点备份。这里的核心类还负责保持游戏节点统计数据,这些数据在搜索过程中会被重复使用。
-
Chapter20/lib/model.py:神经网络及其他与模型相关的功能,例如游戏状态与模型输入之间的转换,以及单局游戏的进行。
-
Chapter20/train.py:将所有内容连接起来的主要训练工具,并生成新最佳网络的模型检查点。
-
Chapter20/play.py:组织模型检查点之间自动化比赛的工具。它接受多个模型文件,并进行一定数量的对局,以形成排行榜。
-
Chapter20/telegram-bot.py:这是一个用于 Telegram 聊天平台的机器人,允许用户与任何模型文件对战并记录统计数据。此机器人曾用于示例结果的人类验证。
现在让我们讨论一下游戏的核心——游戏模型。
游戏模型
整个方法依赖于我们预测行动结果的能力;换句话说,我们需要能够在执行一步之后,得到最终的游戏状态。这比我们在 Atari 环境和 Gym 中遇到的要求要强得多,因为在这些环境中,你无法指定一个想要从其进行行动的状态。因此,我们需要一个包含游戏规则和动态的模型。幸运的是,大多数棋盘游戏都有简单且紧凑的规则集,这使得模型实现变得直截了当。
在我们的示例中,Connect 4 的完整游戏状态由 6 × 7 游戏场地单元的状态和谁将要移动的指示符表示。对我们的示例来说,重要的是使游戏状态表示占用尽可能少的内存,同时仍能高效工作。内存需求由在 MCTS(蒙特卡洛树搜索)过程中存储大量游戏状态的必要性决定。由于我们的游戏树非常庞大,在 MCTS 过程中能够保持的节点越多,最终对移动概率的近似就越准确。因此,理论上,我们希望能够在内存中保留数百万甚至数十亿个游戏状态。
考虑到这一点,游戏状态表示的紧凑性可能会对内存需求和训练过程的性能产生巨大影响。然而,游戏状态表示必须便于操作,例如,在检查棋盘是否有获胜位置、进行操作或从某个状态找到所有有效的操作时。
为了保持这一平衡,两个游戏场地的表示在 Chapter20/lib/game.py 中实现:
-
第一个编码形式非常节省内存,只需 63 位即可编码完整的场地,这使得它在 64 位架构的机器中非常快速且轻量。
-
另一种解码后的游戏场地表示形式是一个长度为 7 的列表,每个条目是一个表示某列磁盘的整数列表。这种形式需要更多内存,但操作起来很方便。
我不会展示 Chapter20/lib/game.py 的完整代码,但如果需要,可以在仓库中找到。这里,我们只需快速查看它提供的常量和函数列表:
GAME_ROWS = 6
GAME_COLS = 7
BITS_IN_LEN = 3
PLAYER_BLACK = 1
PLAYER_WHITE = 0
COUNT_TO_WIN = 4
INITIAL_STATE = encode_lists([[]] * GAME_COLS)
前面代码中的前两个常量定义了游戏场地的维度,并且在代码中到处使用,因此你可以尝试更改它们,实验更大或更小的游戏变体。BITS_IN_LEN 值用于状态编码函数,并指定用于编码列高度(即当前磁盘数)的位数。在 6 × 7 的游戏中,每列最多可以有六个磁盘,因此三个位足以表示从零到七的值。如果更改了行数,您需要相应地调整 BITS_IN_LEN。
PLAYER_BLACK 和 PLAYER_WHITE 值定义了在解码游戏表示中使用的值,最后,COUNT_TO_WIN 设置了获胜所需形成的连线长度。因此,理论上你可以通过在 game.py 中更改四个数字,尝试修改代码并训练代理进行例如在 20 × 40 场地上五子连珠的游戏。
INITIAL_STATE 值包含了一个初始游戏状态的编码表示,其中 GAME_COLS 为空列表。
剩下的代码由函数组成。其中一些是内部使用的,但有些则提供了一个在示例中到处使用的游戏接口。让我们快速列出它们:
-
encode_lists(state_lists):此函数将游戏状态从解码表示转换为编码表示。参数必须是一个包含 GAME_COLS 列表的列表,每个列的内容按从底到顶的顺序指定。换句话说,要将新棋子放置在堆栈的顶部,我们只需要将其附加到相应的列表中。该函数的结果是一个具有 63 位的整数,表示游戏状态。
-
decode_binary(state_int):此函数将字段的整数表示转换回列表形式。
-
possible_moves(state_int):此函数返回一个列表,其中包含可用于从给定编码游戏状态移动的列的索引。列从左到右编号,从零到六。
-
move(state_int, col, player):文件中的核心函数,结合游戏动态和胜负检查。在参数中,它接受编码形式的游戏状态、放置棋子的列以及当前移动的玩家索引。列索引必须有效(即存在于 possible_moves(state_int) 的结果中),否则会引发异常。该函数返回一个包含两个元素的元组:执行移动后的新编码游戏状态以及一个布尔值,表示该移动是否导致玩家获胜。由于玩家只能在自己移动后获胜,因此一个布尔值足够了。当然,也有可能出现平局状态(当没有人获胜,但没有剩余的有效移动时)。此类情况需要在调用 move() 函数后,调用 possible_moves() 函数进行检查。
-
render(state_int):此函数返回一个字符串列表,表示字段的状态。该函数在 Telegram 机器人中用于将字段状态发送给用户。
实现 MCTS
MCTS 在 Chapter20/lib/mcts.py 中实现,并由一个名为 MCTS 的类表示,该类负责执行一批 MCTS 并保持在过程中收集的统计数据。代码不算很大,但仍有一些棘手的部分,所以让我们仔细检查一下。
构造函数没有任何参数,除了 c_puct 常量,它在节点选择过程中使用。Silver 等人 [SSa17] 提到过可以调整它以增加探索性,但我并没有在任何地方重新定义它,也没有对此进行实验。构造函数的主体创建了一个空容器,用于保存有关状态的统计信息:
class MCTS:
def __init__(self, c_puct: float = 1.0):
self.c_puct = c_puct
# count of visits, state_int -> [N(s, a)]
self.visit_count: tt.Dict[int, tt.List[int]] = {}
# total value of the state’s act, state_int -> [W(s, a)]
self.value: tt.Dict[int, tt.List[float]] = {}
# average value of actions, state_int -> [Q(s, a)]
self.value_avg: tt.Dict[int, tt.List[float]] = {}
# prior probability of actions, state_int -> [P(s,a)]
self.probs: tt.Dict[int, tt.List[float]] = {}
所有字典中的关键字都是编码后的游戏状态(整数),值是列表,保存我们拥有的各种动作参数。每个容器上方的注释使用的值符号与 AlphaGo Zero 论文中的符号相同。
clear() 方法清除状态,但不会销毁 MCTS 对象。当我们将当前最佳模型切换为新模型时,收集的统计数据会变得过时,从而触发这一过程:
def clear(self):
self.visit_count.clear()
self.value.clear()
self.value_avg.clear()
self.probs.clear()
find_leaf() 方法在搜索过程中使用,用于对游戏树进行单次遍历,从由 state_int 参数提供的根节点开始,一直向下遍历,直到遇到以下两种情况之一:到达最终游戏状态或发现一个尚未探索的叶节点。在搜索过程中,我们会跟踪访问过的状态和执行过的动作,以便稍后更新节点的统计信息:
def find_leaf(self, state_int: int, player: int):
states = []
actions = []
cur_state = state_int
cur_player = player
value = None
每次循环迭代都处理我们当前所在的游戏状态。对于该状态,我们提取做出决策所需的统计信息:
while not self.is_leaf(cur_state):
states.append(cur_state)
counts = self.visit_count[cur_state]
total_sqrt = m.sqrt(sum(counts))
probs = self.probs[cur_state]
values_avg = self.value_avg[cur_state]
动作的决策基于动作效用(action utility),它是 Q(s,a) 和根据访问次数缩放的先验概率的和。搜索过程的根节点会向概率中添加额外的噪声,以提高搜索过程的探索性。当我们从不同的游戏状态执行 MCTS 时,这种额外的 Dirichlet 噪声(根据论文中使用的参数)确保我们沿路径尝试了不同的动作:
if cur_state == state_int:
noises = np.random.dirichlet([0.03] * game.GAME_COLS)
probs = [0.75 * prob + 0.25 * noise for prob, noise in zip(probs, noises)]
score = [
value + self.c_puct*prob*total_sqrt/(1+count)
for value, prob, count in zip(values_avg, probs, counts)
]
在我们计算出动作的得分后,我们需要为该状态屏蔽无效的动作。(例如,当列已满时,我们不能在顶部再放一个棋盘。)之后,选择得分最高的动作并记录下来:
invalid_actions = set(range(game.GAME_COLS)) - \
set(game.possible_moves(cur_state))
for invalid in invalid_actions:
score[invalid] = -np.inf
action = int(np.argmax(score))
actions.append(action)
为了结束循环,我们请求游戏引擎进行一步操作,返回新的状态以及玩家是否赢得游戏的标识。最终的游戏状态(胜、负或平)不会被添加到 MCTS 统计信息中,因此它们将始终是叶节点。该函数返回叶节点玩家的游戏值(如果尚未到达最终状态,则为 None)、叶节点状态下的当前玩家、搜索过程中访问过的状态列表以及所执行的动作列表:
cur_state, won = game.move(cur_state, action, cur_player)
if won:
value = -1.0
cur_player = 1-cur_player
# check for the draw
moves_count = len(game.possible_moves(cur_state))
if value is None and moves_count == 0:
value = 0.0
return value, cur_state, cur_player, states, actions
MCTS 类的主要入口点是 search_batch() 函数,该函数执行多个批次的搜索。每个搜索包括找到树的叶节点、可选地扩展叶节点以及进行回溯。这里的主要瓶颈是扩展操作,这需要使用神经网络(NN)来获取动作的先验概率和估计的游戏值。为了提高扩展的效率,我们在搜索多个叶节点时使用小批量(mini-batch)方法,但然后在一次神经网络执行中进行扩展。这种方法有一个缺点:由于在一个批次中执行多个 MCTS,我们得到的结果与串行执行时的结果不同。
确实,最初当我们在 MCTS 类中没有存储任何节点时,我们的第一次搜索将扩展根节点,第二次搜索将扩展它的一些子节点,依此类推。然而,单个搜索批次最初只能扩展一个根节点。当然,后来批次中的单独搜索可以沿着不同的游戏路径进行扩展,但最初,mini-batch 扩展在探索方面远不如顺序 MCTS 高效。
为了补偿这一点,我仍然使用小批量,但执行几个小批量:
def is_leaf(self, state_int):
return state_int not in self.probs
def search_batch(self, count, batch_size, state_int, player, net, device="cpu"):
for _ in range(count):
self.search_minibatch(batch_size, state_int, player, net, device)
在小批量搜索中,我们首先执行叶节点搜索,从相同的状态开始。如果搜索已找到最终的游戏状态(此时返回值不等于 None),则不需要扩展,并且我们将结果保存用于备份操作。否则,我们存储叶节点以便稍后扩展:
def search_minibatch(self, count, state_int, player, net, device="cpu"):
backup_queue = []
expand_states = []
expand_players = []
expand_queue = []
planned = set()
for _ in range(count):
value, leaf_state, leaf_player, states, actions = \
self.find_leaf(state_int, player)
if value is not None:
backup_queue.append((value, states, actions))
else:
if leaf_state not in planned:
planned.add(leaf_state)
leaf_state_lists = game.decode_binary(leaf_state)
expand_states.append(leaf_state_lists)
expand_players.append(leaf_player)
expand_queue.append((leaf_state, states, actions))
为了扩展,我们将状态转换为模型所需的形式(在 model.py 库中有一个特殊的函数),并请求我们的网络返回该批状态的先验概率和值。我们将使用这些概率创建节点,并将在最终的统计更新中备份这些值。
if expand_queue:
batch_v = model.state_lists_to_batch(expand_states, expand_players, device)
logits_v, values_v = net(batch_v)
probs_v = F.softmax(logits_v, dim=1)
values = values_v.data.cpu().numpy()[:, 0]
probs = probs_v.data.cpu().numpy()
节点创建仅仅是为每个动作在访问计数和动作值(总值和平均值)中存储零。在先验概率中,我们存储从网络中获得的值:
for (leaf_state, states, actions), value, prob in \
zip(expand_queue, values, probs):
self.visit_count[leaf_state] = [0]*game.GAME_COLS
self.value[leaf_state] = [0.0]*game.GAME_COLS
self.value_avg[leaf_state] = [0.0]*game.GAME_COLS
self.probs[leaf_state] = prob
backup_queue.append((value, states, actions))
备份操作是 MCTS 中的核心过程,它在搜索过程中更新已访问状态的统计数据。所采取动作的访问计数会递增,总值会相加,并且通过访问计数对平均值进行归一化。
在备份过程中正确跟踪游戏的价值非常重要,因为我们有两个对手,并且在每一轮中,价值的符号都会发生变化(因为当前玩家的胜利位置对对手来说是一个失败的游戏状态):
for value, states, actions in backup_queue:
cur_value = -value
for state_int, action in zip(states[::-1], actions[::-1]):
self.visit_count[state_int][action] += 1
self.value[state_int][action] += cur_value
self.value_avg[state_int][action] = self.value[state_int][action] / \
self.visit_count[state_int][action]
cur_value = -cur_value
类中的最终函数返回动作的概率和游戏状态的动作值,使用在 MCTS 过程中收集的统计数据:
def get_policy_value(self, state_int, tau=1):
counts = self.visit_count[state_int]
if tau == 0:
probs = [0.0] * game.GAME_COLS
probs[np.argmax(counts)] = 1.0
else:
counts = [count ** (1.0 / tau) for count in counts]
total = sum(counts)
probs = [count / total for count in counts]
values = self.value_avg[state_int]
return probs, values
在这里,有两种概率计算模式,由τ参数指定。如果τ等于零,选择变得确定性,因为我们选择访问频率最高的动作。在其他情况下,使用的分布为
使用了该方法,这同样提高了探索性。
模型
使用的神经网络是一个残差卷积网络,具有六层,是原始 AlphaGo Zero 方法中使用的网络的简化版。对于输入,我们传递编码后的游戏状态,该状态由两个 6 × 7 的通道组成。第一个通道包含当前玩家的棋子位置,第二个通道在对手的棋子位置处值为 1.0。这样的表示方式使我们能够使网络对于玩家不变,并从当前玩家的视角分析局面。
网络由常见的主体部分与残差卷积滤波器组成。由它们产生的特征被传递到策略头和价值头,这两个部分是卷积层和全连接层的结合。策略头返回每个可能动作(放置棋子的列)的 logits 和一个单一的浮动值。详细内容请见 lib/model.py 文件。
除了模型外,这个文件还包含两个函数。第一个名为 state_lists_to_batch(),它将以列表形式表示的游戏状态批次转换为模型的输入格式。此函数使用一个辅助函数 _encode_list_state,它将状态转换为 NumPy 数组:
def _encode_list_state(dest_np, state_list, who_move):
assert dest_np.shape == OBS_SHAPE
for col_idx, col in enumerate(state_list):
for rev_row_idx, cell in enumerate(col):
row_idx = game.GAME_ROWS - rev_row_idx - 1
if cell == who_move:
dest_np[0, row_idx, col_idx] = 1.0
else:
dest_np[1, row_idx, col_idx] = 1.0
def state_lists_to_batch(state_lists, who_moves_lists, device="cpu"):
assert isinstance(state_lists, list)
batch_size = len(state_lists)
batch = np.zeros((batch_size,) + OBS_SHAPE, dtype=np.float32)
for idx, (state, who_move) in enumerate(zip(state_lists, who_moves_lists)):
_encode_list_state(batch[idx], state, who_move)
return torch.tensor(batch).to(device)
第二种方法叫做 play_game,对于训练和测试过程都非常重要。它的目的是模拟两个神经网络(NNs)之间的游戏,执行 MCTS,并可选地将采取的步骤存储在回放缓冲区中:
def play_game(mcts_stores: tt.Optional[mcts.MCTS | tt.List[mcts.MCTS]],
replay_buffer: tt.Optional[collections.deque], net1: Net, net2: Net,
steps_before_tau_0: int, mcts_searches: int, mcts_batch_size: int,
net1_plays_first: tt.Optional[bool] = None,
device: torch.device = torch.device("cpu")):
if mcts_stores is None:
mcts_stores = [mcts.MCTS(), mcts.MCTS()]
elif isinstance(mcts_stores, mcts.MCTS):
mcts_stores = [mcts_stores, mcts_stores]
如您在前面的代码中看到的,函数接受许多参数:
-
MCTS 类实例,它可以是单个实例、两个实例的列表或 None。我们需要在这里保持灵活性,以适应此函数的不同用途。
-
一个可选的回放缓冲区。
-
在游戏中使用的神经网络(NNs)。
-
在进行行动概率计算的参数从 1 更改为 0 之前,需要进行的游戏步骤数。
-
要执行的 MCTS 数量。
-
MCTS 批量大小。
-
哪个玩家先行动。
在游戏循环之前,我们初始化游戏状态并选择第一个玩家。如果没有提供谁先行动的信息,则随机选择:
state = game.INITIAL_STATE
nets = [net1, net2]
if net1_plays_first is None:
cur_player = np.random.choice(2)
else:
cur_player = 0 if net1_plays_first else 1
step = 0
tau = 1 if steps_before_tau_0 > 0 else 0
game_history = []
在每一回合,我们执行 MCTS 以填充统计数据,然后获取行动的概率,随后通过采样得到行动:
result = None
net1_result = None
while result is None:
mcts_stores[cur_player].search_batch(
mcts_searches, mcts_batch_size, state,
cur_player, nets[cur_player], device=device)
probs, _ = mcts_stores[cur_player].get_policy_value(state, tau=tau)
game_history.append((state, cur_player, probs))
action = np.random.choice(game.GAME_COLS, p=probs)
然后,使用游戏引擎模块中的函数更新游戏状态,并处理不同的游戏结束情况(如胜利或平局):
if action not in game.possible_moves(state):
print("Impossible action selected")
state, won = game.move(state, action, cur_player)
if won:
result = 1
net1_result = 1 if cur_player == 0 else -1
break
cur_player = 1-cur_player
# check the draw case
if len(game.possible_moves(state)) == 0:
result = 0
net1_result = 0
break
step += 1
if step >= steps_before_tau_0:
tau = 0
在函数的末尾,我们将从当前玩家的视角填充回放缓冲区,记录行动的概率和游戏结果。这些数据将用于训练网络:
if replay_buffer is not None:
for state, cur_player, probs in reversed(game_history):
replay_buffer.append((state, cur_player, probs, result))
result = -result
return net1_result, step
训练
拥有所有这些功能后,训练过程只需将它们按正确顺序组合。训练程序可以在 train.py 中找到,里面包含的逻辑已经描述过:在循环中,我们当前最好的模型不断地与自己对弈,将步骤保存到回放缓冲区。另一个网络使用这些数据进行训练,最小化从 MCTS 采样的行动概率和策略头结果之间的交叉熵。同时,价值头预测的均方误差(MSE),即游戏结果与实际游戏结果之间的误差,也会加入到总损失中。
定期地,正在训练的网络和当前最佳网络进行 100 场比赛,如果当前网络能够赢得其中超过 60%的比赛,则会同步网络的权重。这个过程会不断重复,最终希望找到越来越精通游戏的模型。
测试与比较
在训练过程中,每当当前最佳模型被训练好的模型替换时,都会保存模型的权重。因此,我们得到了多个强度不同的智能体。从理论上讲,后来的模型应该比前面的模型更好,但我们希望亲自验证这一点。为此,有一个工具 play.py,它接受多个模型文件,并进行锦标赛,每个模型与其他所有模型进行指定回合数的比赛。每个模型的获胜次数将代表该模型的相对强度。
结果
为了加快训练速度,我故意将训练过程中的超参数设置为较小的值。例如,在自对弈的每一步中,只执行了 10 次 MCTS,每次使用一个批量大小为 8 的小批次。这与高效的小批次 MCTS 和快速的游戏引擎相结合,使得训练非常迅速。
基本上,在仅仅进行了一小时的训练和 2,500 场自对弈比赛后,产生的模型已经足够复杂,可以让人享受对战的乐趣。当然,它的水平远低于一个孩子的水平,但它展现出一些基本的策略,而且每隔一回合才犯一次错误,这已经是很好的进步。
我已经进行了两轮训练,第一次学习率为 0.1,第二次学习率为 0.001。每个实验训练了 10 小时,进行了 40K 场游戏。在图 20.3 中,您可以看到关于胜率的图表(当前评估策略与当前最佳策略的胜负比)。如您所见,两个学习率值都在 0.5 附近波动,有时会激增到 0.8-0.9:
图 20.3:使用两种学习率进行训练的胜率;学习率=0.1(左)和学习率=0.001(右)
图 20.4 显示了两次实验的总损失情况,没有明显的趋势。这是由于当前最佳策略的不断切换,导致训练好的模型不断被重新训练。
图 20.4:使用两种学习率进行训练的总损失;学习率=0.1(左)和学习率=0.001(右)
锦标赛验证因模型种类繁多而变得复杂,因为每对模型需要进行若干场比赛以评估它们的强度。一开始,我为每个在每次实验中存储的模型运行了 10 轮(分别进行)。为此,您可以像这样运行 play.py 工具:
./play.py --cuda -r 10 saves/v2/best\_* > semi-v2.txt
但是对于 100 个模型来说,可能需要一些时间,因为每个模型需要与其他所有模型进行 10 回合的比赛。
所有测试结束后,该工具会在控制台上打印所有比赛的结果以及模型的排行榜。以下是实验 1(学习率=0.1)的前 10 名:
saves/t1/best_088_39300.dat: w=1027, l=732, d=1
saves/t1/best_025_09900.dat: w=1024, l=735, d=1
saves/t1/best_022_08200.dat: w=1023, l=737, d=0
saves/t1/best_021_08100.dat: w=1017, l=743, d=0
saves/t1/best_009_03400.dat: w=1010, l=749, d=1
saves/t1/best_014_04700.dat: w=1003, l=757, d=0
saves/t1/best_008_02700.dat: w=998, l=760, d=2
saves/t1/best_010_03500.dat: w=997, l=762, d=1
saves/t1/best_029_11800.dat: w=991, l=768, d=1
saves/t1/best_007_02300.dat: w=980, l=779, d=1
以下是实验 2(学习率=0.001)的前 10 名:
saves/t2/best_069_41500.dat: w=1023, l=757, d=0
saves/t2/best_070_42200.dat: w=1016, l=764, d=0
saves/t2/best_066_38900.dat: w=1005, l=775, d=0
saves/t2/best_071_42600.dat: w=1003, l=777, d=0
saves/t2/best_059_33700.dat: w=999, l=781, d=0
saves/t2/best_049_27500.dat: w=990, l=790, d=0
saves/t2/best_068_41300.dat: w=990, l=789, d=1
saves/t2/best_048_26700.dat: w=983, l=796, d=1
saves/t2/best_058_32100.dat: w=982, l=797, d=1
saves/t2/best_076_45200.dat: w=982, l=795, d=3
为了检查我们的训练是否生成了更好的模型,我在图 20.5 中绘制了模型的胜率与其索引的关系。Y 轴是相对胜率,X 轴是索引(训练过程中索引会增加)。如你所见,每个实验中的模型质量都在提高,但学习率较小的实验有更一致的表现。
图 20.5:训练过程中最佳模型的胜率,学习率=0.1(左)和学习率=0.001(右)
我没有对训练做太多的超参数调优,所以它们肯定可以改进。你可以自己尝试实验一下。
将结果与不同学习率进行比较也很有趣。为此,我选取了每个实验中的 10 个最佳模型,并进行了 10 轮比赛。以下是该比赛的前 10 名排行榜:
saves/t2/best_059_33700.dat: w=242, l=138, d=0
saves/t2/best_058_32100.dat: w=223, l=157, d=0
saves/t2/best_071_42600.dat: w=217, l=163, d=0
saves/t2/best_068_41300.dat: w=210, l=170, d=0
saves/t2/best_076_45200.dat: w=208, l=171, d=1
saves/t2/best_048_26700.dat: w=202, l=178, d=0
saves/t2/best_069_41500.dat: w=201, l=179, d=0
saves/t2/best_049_27500.dat: w=199, l=181, d=0
saves/t2/best_070_42200.dat: w=197, l=183, d=0
saves/t1/best_021_08100.dat: w=192, l=188, d=0
如你所见,使用学习率为 0.001 的模型在联合比赛中领先,优势明显。
MuZero
AlphaGo Zero(2017 年发布)的继任者是 MuZero,这一方法由 DeepMind 的 Schrittwieser 等人在 2020 年发布的论文《通过学习的模型规划掌握 Atari、围棋、国际象棋和将棋》[Sch+20]中描述。在该方法中,作者尝试通过去除对精确游戏模型的需求来泛化该方法,但仍将其保持在基于模型的范畴内。正如我们在 AlphaGo Zero 的描述中所见,游戏模型在训练过程中被广泛使用:在 MCTS 阶段,我们使用游戏模型来获取当前状态下的可用动作以及应用该动作后的新游戏状态。此外,游戏模型还提供了最终的游戏结果:我们是赢了还是输了游戏。
乍一看,似乎几乎不可能从训练过程中去除模型,但 MuZero 不仅展示了如何做到这一点,而且还打破了先前 AlphaGo Zero 在围棋、国际象棋和将棋中的记录,并在 57 个 Atari 游戏中建立了最先进的成果。
在本章的这一部分,我们将详细讨论该方法,实现它,并与使用 Connect 4 的 AlphaGo Zero 进行比较。
高级模型
首先,让我们从高层次来看 MuZero。与 AlphaGo Zero 一样,核心是 MCTS,它会被多次执行,用于计算关于当前位于树根的游戏状态可能未来结果的统计数据。在这个搜索之后,我们计算访问计数器,指示动作执行的频率。
但与其使用游戏模型来回答“如果我从这个状态执行这个动作,我会得到什么状态?”这个问题,MuZero 引入了两个额外的神经网络:
-
表示 h𝜃 →s:计算游戏观察的隐藏状态
-
动力学 g𝜃 →r,s′:将动作 a 应用于隐藏状态 s,将其转化为下一个状态 s′(并获得即时奖励 r)
如你所记得,在 AlphaGo Zero 中,只使用了一个网络 f𝜃 →π,v,它预测了当前状态 s 的策略π和值 v。MuZero 的操作使用了三个网络,它们同时进行训练。我稍后会解释训练是如何进行的,但现在我们先集中讨论 MCTS。
在图 20.6 中,MCTS 过程以示意图的方式展示,指明了我们使用神经网络计算的值。作为第一步,我们使用表示网络 h[𝜃],计算当前游戏观察 o 的隐藏状态 s⁰。
得到隐藏状态后,我们可以使用网络 f[𝜃]来计算该状态的策略π⁰和值 v⁰——这些量表示我们应该采取的动作(π⁰)以及这些动作的预期结果(v⁰)。
我们使用策略和价值(结合动作的访问计数统计)来计算该动作的效用值 U(s,a),与 AlphaGo Zero 中的方法类似。然后,选择具有最大效用值的动作进行树的下降。如果这是我们第一次从此状态节点选择该动作(换句话说,该节点尚未展开),我们使用神经网络 g𝜃 →r¹,s¹来获得即时奖励 r¹和下一个隐藏状态 s¹。
图 20.6:MuZero 中的蒙特卡洛树搜索
这个过程会一遍又一遍地重复数百次,累积动作的访问计数器,不断扩展树中的节点。在每次节点扩展时,从 f[𝜃]获得的节点值会被添加到沿着搜索路径的所有节点中,直到树的根部。在 AlphaGo Zero 的论文中,这个过程被称为“备份”,而在 MuZero 的论文中则使用了“反向传播”这个术语。但本质上,含义是相同的——将扩展节点的值添加到树的根部,改变符号。
经过一段时间(在原始 MuZero 方法中是 800 次搜索),动作的访问次数已经足够准确(或者我们认为它们足够准确),可以用作选择动作和训练时策略的近似值。
训练过程
如上所述,MCTS 用于单一的游戏状态(位于树的根部)。在所有搜索轮次结束后,我们根据搜索过程中执行的动作频率,从该根状态中选择一个动作。然后,在环境中执行选定的动作,获得下一个状态和奖励。之后,使用下一个状态作为搜索树的根,执行另一个 MCTS。
这个过程允许我们生成回合。我们将它们存储在回放缓存中并用于训练。为了准备训练批次,我们从回放缓存中抽取一个回合并随机选择回合中的偏移量。然后,从回合中的这个位置开始,我们展开回合直到固定的步数(在 MuZero 论文中,使用的是五步展开)。在展开的每个步骤中,以下数据会被累积:
-
从 MCTS 获取的动作频率作为策略目标(使用交叉熵损失训练)。
-
到回合结束为止的折扣奖励和奖励总和被用作价值目标(使用均方误差损失训练)。
-
即时奖励被用作动态网络预测的奖励值的目标(同样使用均方误差损失进行训练)。
除此之外,我们记住在每个展开步骤中采取的动作,这将作为动态网络的输入,g𝜃 →r,s′。
一旦批次生成,我们将表示网络 h𝜃 应用到游戏观察值(展开回合的第一个步骤)。然后,我们通过计算当前隐藏状态下的策略 π 和价值 v 来重复展开过程,计算它们的损失,并执行动态网络步骤以获得下一个隐藏状态。这个过程会重复五步(展开的长度)。Schrittwieser 等人通过将梯度按 0.5 的比例缩放来处理展开的步骤,但在我的实现中,我只是将损失乘以这个常数来获得相同的效果。
使用 MuZero 的 Connect 4
现在我们已经讨论了方法,接下来让我们查看其在 Connect 4 中的实现及结果。实现由几个模块组成:
-
lib/muzero.py:包含 MCTS 数据结构和函数、神经网络和批次生成逻辑
-
train-mu.py:训练循环,实现自我对弈以生成回合,训练,并定期验证当前训练的模型与最佳模型的对比(与 AlphaGo Zero 方法相同)。
-
play-mu.py:执行一系列模型对战,以获得它们的排名
超参数和 MCTS 树节点
大部分 MuZero 超参数被放入一个单独的数据类中,以简化在代码中传递它们:
@dataclass
class MuZeroParams:
actions_count: int = game.GAME_COLS
max_moves: int = game.GAME_COLS * game.GAME_ROWS >> 2 + 1
dirichlet_alpha: float = 0.3
discount: float = 1.0
unroll_steps: int = 5
pb_c_base: int = 19652
pb_c_init: float = 1.25
dev: torch.device = torch.device("cpu")
我不会在这里解释这些参数。我们在讨论相关代码片段时会进行解释。
MuZero 的 MCTS 实现与 AlphaGo Zero 的实现有所不同。在我们的 AlphaGo Zero 实现中,每个 MCTS 节点都有一个唯一的游戏状态标识符,这个标识符是一个整数。因此,我们将整个树保存在多个字典中,将游戏状态映射到节点的属性,比如访问计数器、子节点的状态等等。每次看到游戏状态时,我们只需更新这些字典。
然而,在 MuZero 中,每个 MCTS 节点现在由一个隐藏状态标识,该隐藏状态是一个浮点数列表(因为隐藏状态是由神经网络生成的)。因此,我们无法直接比较两个隐藏状态以检查它们是否相同。为了解决这个问题,我们现在以“正确”的方式存储树——作为引用子节点的节点,这从内存的角度来看效率较低。以下是核心 MCTS 数据结构:表示树节点的对象。对于构造函数,我们只需创建一个空的未展开节点:
class MCTSNode:
def __init__(self, prior: float, first_plays: bool):
self.first_plays: bool = first_plays
self.visit_count = 0
self.value_sum = 0.0
self.prior = prior
self.children: tt.Dict[Action, MCTSNode] = {}
# node is not expanded, so has no hidden state
self.h = None
# predicted reward
self.r = 0.0
节点的扩展在 expand_node 方法中实现,这将在介绍模型之后展示。现在,如果节点有子节点(动作),并且通过神经网络计算出了隐藏状态、策略和价值,则节点会被扩展。节点的价值是通过将所有子节点的价值求和并除以访问次数得到的:
@property
def is_expanded(self) -> bool:
return bool(self.children)
@property
def value(self) -> float:
return 0 if not self.visit_count else self.value_sum / self.visit_count
select_child 方法在 MCTS 搜索过程中执行动作选择。这个选择通过选择由 ucb_value 函数返回的最大值对应的子节点来完成,这个函数将在稍后展示:
def select_child(self, params: MuZeroParams, min_max: MinMaxStats) -> \
tt.Tuple[Action, "MCTSNode"]:
max_ucb, best_action, best_node = None, None, None
for action, node in self.children.items():
ucb = ucb_value(params, self, node, min_max)
if max_ucb is None or max_ucb < ucb:
max_ucb = ucb
best_action = action
best_node = node
return best_action, best_node
ucb_value 方法实现了节点的上置信界(UCB)计算,它与我们为 AlphaGo Zero 讨论的公式非常相似。UCB 是从节点的价值和先验乘以一个系数计算得到的:
def ucb_value(params: MuZeroParams, parent: MCTSNode, child: MCTSNode,
min_max: MinMaxStats) -> float:
pb_c = m.log((parent.visit_count + params.pb_c_base + 1) /
params.pb_c_base) + params.pb_c_init
pb_c *= m.sqrt(parent.visit_count) / (child.visit_count + 1)
prior_score = pb_c * child.prior
value_score = 0.0
if child.visit_count > 0:
value_score = min_max.normalize(child.value + child.r)
return prior_score + value_score
MCTSNode 类的另一个方法是 get_act_probs(),它返回从访问计数器获得的近似概率。这些概率作为策略网络训练的目标。这个方法有一个特殊的“温度系数”,允许我们在训练的不同阶段调整熵:如果温度接近零,我们会将较高的概率分配给访问次数最多的动作。如果温度较高,分布会变得更加均匀:
def get_act_probs(self, t: float = 1) -> tt.List[float]:
child_visits = sum(map(lambda n: n.visit_count, self.children.values()))
p = np.array([(child.visit_count / child_visits) ** (1 / t)
for _, child in sorted(self.children.items())])
p /= sum(p)
return list(p)
MCTSNode 的最后一个方法是 select_action(),它使用 get_act_probs()方法来选择动作,并处理以下几种特殊情况:
-
如果节点中没有子节点,则动作是随机执行的
-
如果温度系数太小,我们选择访问次数最多的动作
-
否则,我们使用 get_act_probs()根据温度系数获取每个动作的概率,并根据这些概率选择动作
def select_action(self, t: float, params: MuZeroParams) -> Action:
act_vals = list(sorted(self.children.keys()))
if not act_vals:
res = np.random.choice(params.actions_count)
elif t < 0.0001:
res, _ = max(self.children.items(), key=lambda p: p[1].visit_count)
else:
p = self.get_act_probs(t)
res = int(np.random.choice(act_vals, p=p))
return res
前面的代码可能看起来有点复杂且与当前内容无关,但当我们讨论 MuZero 模型和 MCTS 搜索过程时,它会变得更加清晰:
模型
正如我们之前提到的,MuZero 使用了三个神经网络(NN)用于不同的目的。让我们来看看它们。你可以在 GitHub 的 lib/muzero.py 模块中找到所有相关代码。
第一个模型是表示模型,h𝜃 →s,它将游戏观测映射到隐藏状态。观测与 AlphaGo Zero 代码中的完全相同——我们有一个 2 × 6 × 7 大小的张量,其中 6 × 7 是棋盘的大小,两个平面分别是当前玩家和对手棋子的独热编码位置。隐藏状态的维度由超参数 HIDDEN_STATE_SIZE=64 给出:
class ReprModel(nn.Module):
def __init__(self, input_shape: tt.Tuple[int, ...]):
super(ReprModel, self).__init__()
self.conv_in = nn.Sequential(
nn.Conv2d(input_shape[0], NUM_FILTERS, kernel_size=3, padding=1),
nn.BatchNorm2d(NUM_FILTERS),
nn.LeakyReLU()
)
# layers with residual
self.conv_1 = nn.Sequential(
nn.Conv2d(NUM_FILTERS, NUM_FILTERS, kernel_size=3, padding=1),
nn.BatchNorm2d(NUM_FILTERS),
nn.LeakyReLU()
)
self.conv_2 = nn.Sequential(
nn.Conv2d(NUM_FILTERS, NUM_FILTERS, kernel_size=3, padding=1),
nn.BatchNorm2d(NUM_FILTERS),
nn.LeakyReLU()
)
self.conv_3 = nn.Sequential(
nn.Conv2d(NUM_FILTERS, NUM_FILTERS, kernel_size=3, padding=1),
nn.BatchNorm2d(NUM_FILTERS),
nn.LeakyReLU()
)
self.conv_4 = nn.Sequential(
nn.Conv2d(NUM_FILTERS, NUM_FILTERS, kernel_size=3, padding=1),
nn.BatchNorm2d(NUM_FILTERS),
nn.LeakyReLU()
)
self.conv_5 = nn.Sequential(
nn.Conv2d(NUM_FILTERS, NUM_FILTERS, kernel_size=3, padding=1),
nn.BatchNorm2d(NUM_FILTERS),
nn.LeakyReLU(),
)
self.conv_out = nn.Sequential(
nn.Conv2d(NUM_FILTERS, 16, kernel_size=1),
nn.BatchNorm2d(16),
nn.LeakyReLU(),
nn.Flatten()
)
body_shape = (NUM_FILTERS,) + input_shape[1:]
size = self.conv_out(torch.zeros(1, *body_shape)).size()[-1]
self.out = nn.Sequential(
nn.Linear(size, 128),
nn.ReLU(),
nn.Linear(128, HIDDEN_STATE_SIZE),
)
网络的结构几乎与 AlphaGo Zero 示例中的相同,唯一的区别是它返回隐藏状态向量,而不是策略和价值。
由于网络块是残差的,每一层需要特殊处理:
def forward(self, x):
v = self.conv_in(x)
v = v + self.conv_1(v)
v = v + self.conv_2(v)
v = v + self.conv_3(v)
v = v + self.conv_4(v)
v = v + self.conv_5(v)
c_out = self.conv_out(v)
out = self.out(c_out)
return out
第二个模型是预测模型,f𝜃 →π,v,它接受隐藏状态并返回策略和值。在我的示例中,我为策略和值使用了两层头:
class PredModel(nn.Module):
def __init__(self, actions: int):
super(PredModel, self).__init__()
self.policy = nn.Sequential(
nn.Linear(HIDDEN_STATE_SIZE, 128),
nn.ReLU(),
nn.Linear(128, actions),
)
self.value = nn.Sequential(
nn.Linear(HIDDEN_STATE_SIZE, 128),
nn.ReLU(),
nn.Linear(128, 1),
)
def forward(self, x) -> tt.Tuple[torch.Tensor, torch.Tensor]:
return self.policy(x), self.value(x).squeeze(1)
我们的第三个模型是动态模型,g𝜃 →r,s′,它接受隐藏状态和独热编码的动作,并返回即时奖励和下一个状态:
class DynamicsModel(nn.Module):
def __init__(self, actions: int):
super(DynamicsModel, self).__init__()
self.reward = nn.Sequential(
nn.Linear(HIDDEN_STATE_SIZE + actions, 128),
nn.ReLU(),
nn.Linear(128, 1),
)
self.hidden = nn.Sequential(
nn.Linear(HIDDEN_STATE_SIZE + actions, 128),
nn.ReLU(),
nn.Linear(128, 128),
nn.ReLU(),
nn.Linear(128, HIDDEN_STATE_SIZE),
)
def forward(self, h: torch.Tensor, a: torch.Tensor) -> \
tt.Tuple[torch.Tensor, torch.Tensor]:
x = torch.hstack((h, a))
return self.reward(x).squeeze(1), self.hidden(x)
为了方便起见,所有三个网络都保存在 MuZeroModels 类中,它提供了所需的功能:
class MuZeroModels:
def __init__(self, input_shape: tt.Tuple[int, ...], actions: int):
self.repr = ReprModel(input_shape)
self.pred = PredModel(actions)
self.dynamics = DynamicsModel(actions)
def to(self, dev: torch.device):
self.repr.to(dev)
self.pred.to(dev)
self.dynamics.to(dev)
该类提供了从其他实例同步网络的方法。我们将使用它来存储验证后的最佳模型。
此外,还有两个方法用于存储和加载网络的权重:
def sync(self, src: "MuZeroModels"):
self.repr.load_state_dict(src.repr.state_dict())
self.pred.load_state_dict(src.pred.state_dict())
self.dynamics.load_state_dict(src.dynamics.state_dict())
def get_state_dict(self) -> tt.Dict[str, dict]:
return {
"repr": self.repr.state_dict(),
"pred": self.pred.state_dict(),
"dynamics": self.dynamics.state_dict(),
}
def set_state_dict(self, d: dict):
self.repr.load_state_dict(d[’repr’])
self.pred.load_state_dict(d[’pred’])
self.dynamics.load_state_dict(d[’dynamics’])
现在我们已经了解了模型,接下来就可以进入实现 MCTS 逻辑和游戏循环的函数。
MCTS 搜索
首先,我们有两个执行类似任务的函数,但在不同的情况下:
-
make_expanded_root()从给定的游戏状态创建 MCTS 树的根节点。对于根节点,我们没有父节点,因此不需要应用动态神经网络;相反,我们通过表示网络从编码的游戏观测中获取节点的隐藏状态。
-
expand_node()扩展非根 MCTS 节点。在这种情况下,我们使用神经网络通过父节点的隐藏状态生成子节点的隐藏状态。
在第一个函数的开始,我们创建一个新的 MCTSNode,将游戏状态解码为列表表示,并将其转换为张量。然后,使用表示网络获取节点的隐藏状态:
def make_expanded_root(player_idx: int, game_state_int: int, params: MuZeroParams,
models: MuZeroModels, min_max: MinMaxStats) -> MCTSNode:
root = MCTSNode(1.0, player_idx == 0)
state_list = game.decode_binary(game_state_int)
state_t = state_lists_to_batch([state_list], [player_idx], device=params.dev)
h_t = models.repr(state_t)
root.h = h_t[0].cpu().numpy()
使用隐藏状态,我们获得节点的策略和价值,并将策略的对数值转化为概率,然后添加一些随机噪声以增加探索性:
p_t, v_t = models.pred(h_t)
# logits to probs
p_t.exp_()
probs_t = p_t.squeeze(0) / p_t.sum()
probs = probs_t.cpu().numpy()
# add dirichlet noise
noises = np.random.dirichlet([params.dirichlet_alpha] * params.actions_count)
probs = probs * 0.75 + noises * 0.25
由于我们得到了概率,我们创建了子节点并反向传播节点的价值。反向传播(backpropagate())方法稍后会进行讨论;它会沿着搜索路径增加节点的价值。对于根节点,我们的搜索路径只有根节点,所以只有一步(在下一个方法 expand_node()中,路径可能会更长):
for a, prob in enumerate(probs):
root.children[a] = MCTSNode(prob, not root.first_plays)
v = v_t.cpu().item()
backpropagate([root], v, root.first_plays, params, min_max)
return root
expand_node()方法类似,但用于非根节点,因此它使用父节点的隐藏状态执行动态步骤:
def expand_node(parent: MCTSNode, node: MCTSNode, last_action: Action,
params: MuZeroParams, models: MuZeroModels) -> float:
h_t = torch.as_tensor(parent.h, dtype=torch.float32, device=params.dev)
h_t.unsqueeze_(0)
p_t, v_t = models.pred(h_t)
a_t = torch.zeros(params.actions_count, dtype=torch.float32, device=params.dev)
a_t[last_action] = 1.0
a_t.unsqueeze_(0)
r_t, h_next_t = models.dynamics(h_t, a_t)
node.h = h_next_t[0].cpu().numpy()
node.r = float(r_t[0].cpu().item())
其余的逻辑相同,唯一不同的是非根节点没有添加噪声:
p_t.squeeze_(0)
p_t.exp_()
probs_t = p_t / p_t.sum()
probs = probs_t.cpu().numpy()
for a, prob in enumerate(probs):
node.children[a] = MCTSNode(prob, not node.first_plays)
return float(v_t.cpu().item())
backpropagate()函数用于将折扣值添加到搜索路径上的节点。每个级别的值符号会发生变化,以表明玩家的回合正在变化。所以,我们的正值意味着对手的负值,反之亦然:
def backpropagate(search_path: tt.List[MCTSNode], value: float, first_plays: bool,
params: MuZeroParams, min_max: MinMaxStats):
for node in reversed(search_path):
node.value_sum += value if node.first_plays == first_plays else -value
node.visit_count += 1
value = node.r + params.discount * value
min_max.update(value)
MinMaxStats类的实例用于在搜索过程中保存树的最小值和最大值。然后,这些极值被用来规范化结果值。
有了这些函数,让我们现在来看一下实际的 MCTS 搜索逻辑。首先,我们创建一个根节点,然后执行几轮搜索。在每一轮中,我们通过跟随 UCB 值函数来遍历树。当我们找到一个未展开的节点时,我们展开它,并将值回传到树的根节点。
@torch.no_grad()
def run_mcts(player_idx: int, root_state_int: int, params: MuZeroParams,
models: MuZeroModels, min_max: MinMaxStats,
search_rounds: int = 800) -> MCTSNode:
root = make_expanded_root(player_idx, root_state_int, params, models, min_max)
for _ in range(search_rounds):
search_path = [root]
parent_node = None
last_action = 0
node = root
while node.is_expanded:
action, new_node = node.select_child(params, min_max)
last_action = action
parent_node = node
node = new_node
search_path.append(new_node)
value = expand_node(parent_node, node, last_action, params, models)
backpropagate(search_path, value, node.first_plays, params, min_max)
return root
如你所见,这个实现使用了神经网络,但没有对节点进行批处理。MuZero 的 MCTS 过程的问题在于搜索过程是确定性的,并且由节点的值(当节点被展开时更新)和访问计数器驱动。因此,批处理没有效果,因为如果不展开节点,重复搜索将导致树中的相同路径,因此必须一个个地展开。这是使用神经网络的一个非常低效的方式,负面地影响了整体性能。在这里,我的目的不是实现 MuZero 的最优版本,而是为你展示一个可行的原型,所以我没有进行优化。作为一个练习,你可以修改实现,使得多个进程并行进行 MCTS 搜索。作为另一种选择(或者附加功能),你可以在 MCTS 搜索过程中添加噪声,并像我们讨论 AlphaGo Zero 时那样使用批处理。
训练数据和游戏过程
为了存储训练数据,我们有一个Episode类,它保存一系列EpisodeStep对象,并附带额外的信息:
@dataclass
class EpisodeStep:
state: int
player_idx: int
action: int
reward: int
class Episode:
def __init__(self):
self.steps: tt.List[EpisodeStep] = []
self.action_probs: tt.List[tt.List[float]] = []
self.root_values: tt.List[float] = []
def __len__(self):
return len(self.steps)
def add_step(self, step: EpisodeStep, node: MCTSNode):
self.steps.append(step)
self.action_probs.append(node.get_act_probs())
self.root_values.append(node.value)
现在,让我们来看一下play_game()函数,它使用 MCTS 搜索多次来玩完整的一局游戏。在函数的开始部分,我们创建游戏状态和所需的对象:
@torch.no_grad()
def play_game(
player1: MuZeroModels, player2: MuZeroModels, params: MuZeroParams,
temperature: float, init_state: tt.Optional[int] = None
) -> tt.Tuple[int, Episode]:
episode = Episode()
state = game.INITIAL_STATE if init_state is None else init_state
players = [player1, player2]
player_idx = 0
reward = 0
min_max = MinMaxStats()
在游戏循环的开始,我们检查游戏是否平局,然后运行 MCTS 搜索以积累统计数据。之后,我们使用从动作频率(而不是 UCB 值)中随机采样来选择一个动作:
while True:
possible_actions = game.possible_moves(state)
if not possible_actions:
break
root_node = run_mcts(player_idx, state, params, players[player_idx], min_max)
action = root_node.select_action(temperature, params)
# act randomly on wrong move
if action not in possible_actions:
action = int(np.random.choice(possible_actions))
一旦选择了动作,我们就会在游戏环境中执行一个动作,并检查是否有胜负情况。然后,过程会重复:
new_state, won = game.move(state, action, player_idx)
if won:
if player_idx == 0:
reward = 1
else:
reward = -1
step = EpisodeStep(state, player_idx, action, reward)
episode.add_step(step, root_node)
if won:
break
player_idx = (player_idx + 1) % 2
state = new_state
return reward, episode
最后,我们有一个方法从回放缓冲区中抽取一批训练数据(回放缓冲区是一个Episode对象的列表)。如果你记得,训练数据是通过从一个随机位置开始展开随机回合来创建的。这是为了应用动态网络并用实际数据优化它。因此,我们的批量数据不是一个张量,而是一个张量的列表,每个张量都是展开过程中一个步骤的表示。
为了准备批量采样,我们创建了所需大小的空列表:
def sample_batch(
episode_buffer: tt.Deque[Episode], batch_size: int, params: MuZeroParams,
) -> tt.Tuple[
torch.Tensor, tt.Tuple[torch.Tensor, ...], tt.Tuple[torch.Tensor, ...],
tt.Tuple[torch.Tensor, ...], tt.Tuple[torch.Tensor, ...],
]:
states = []
player_indices = []
actions = [[] for _ in range(params.unroll_steps)]
policy_targets = [[] for _ in range(params.unroll_steps)]
rewards = [[] for _ in range(params.unroll_steps)]
values = [[] for _ in range(params.unroll_steps)]
然后我们随机抽取一个回合,并在这个回合中选择一个偏移位置:
for episode in np.random.choice(episode_buffer, batch_size):
assert isinstance(episode, Episode)
ofs = np.random.choice(len(episode) - params.unroll_steps)
state = game.decode_binary(episode.steps[ofs].state)
states.append(state)
player_indices.append(episode.steps[ofs].player_idx)
之后,我们会展开一个特定步数(本文中为五步)。在每一步,我们记住动作、即时奖励和动作的概率。之后,我们通过对直到回合结束的折扣奖励求和来计算值目标:
for s in range(params.unroll_steps):
full_ofs = ofs + s
actions[s].append(episode.steps[full_ofs].action)
rewards[s].append(episode.steps[full_ofs].reward)
policy_targets[s].append(episode.action_probs[full_ofs])
value = 0.0
for step in reversed(episode.steps[full_ofs:]):
value *= params.discount
value += step.reward
values[s].append(value)
在数据准备好后,我们将其转换为张量。动作使用 eye() NumPy 函数和索引进行独热编码:
states_t = state_lists_to_batch(states, player_indices, device=params.dev)
res_actions = tuple(
torch.as_tensor(np.eye(params.actions_count)[a],
dtype=torch.float32, device=params.dev)
for a in actions
)
res_policies = tuple(
torch.as_tensor(p, dtype=torch.float32, device=params.dev)
for p in policy_targets
)
res_rewards = tuple(
torch.as_tensor(r, dtype=torch.float32, device=params.dev)
for r in rewards
)
res_values = tuple(
torch.as_tensor(v, dtype=torch.float32, device=params.dev)
for v in values
)
return states_t, res_actions, res_policies, res_rewards, res_values
我这里不打算展示完整的训练循环;我们使用当前最佳模型进行自我对弈,以填充回放缓冲区。完整的训练代码在 train-mu.py 模块中。以下代码用于优化网络:
states_t, actions, policy_tgt, rewards_tgt, values_tgt = \
mu.sample_batch(replay_buffer, BATCH_SIZE, params)
optimizer.zero_grad()
h_t = net.repr(states_t)
loss_p_full_t = None
loss_v_full_t = None
loss_r_full_t = None
for step in range(params.unroll_steps):
policy_t, values_t = net.pred(h_t)
loss_p_t = F.cross_entropy(policy_t, policy_tgt[step])
loss_v_t = F.mse_loss(values_t, values_tgt[step])
# dynamic step
rewards_t, h_t = net.dynamics(h_t, actions[step])
loss_r_t = F.mse_loss(rewards_t, rewards_tgt[step])
if step == 0:
loss_p_full_t = loss_p_t
loss_v_full_t = loss_v_t
loss_r_full_t = loss_r_t
else:
loss_p_full_t += loss_p_t * 0.5
loss_v_full_t += loss_v_t * 0.5
loss_r_full_t += loss_r_t * 0.5
loss_full_t = loss_v_full_t + loss_p_full_t + loss_r_full_t
loss_full_t.backward()
optimizer.step()
MuZero 结果
我进行了 15 小时的训练,进行了 3400 个回合(你看,训练速度并不快)。策略和价值损失如图 20.7 所示。正如自我对弈训练中常见的那样,图表没有明显的趋势:
图 20.7:MuZero 训练的策略(左)和值(右)损失
在训练过程中,存储了近 200 个当前最好的模型,我通过使用 play-mu.py 脚本在比赛模式下进行检查。以下是前 10 个模型:
saves/mu-t5-6/best_010_00210.dat: w=339, l=41, d=0
saves/mu-t5-6/best_015_00260.dat: w=298, l=82, d=0
saves/mu-t5-6/best_155_02510.dat: w=287, l=93, d=0
saves/mu-t5-6/best_150_02460.dat: w=273, l=107, d=0
saves/mu-t5-6/best_140_02360.dat: w=267, l=113, d=0
saves/mu-t5-6/best_145_02410.dat: w=266, l=114, d=0
saves/mu-t5-6/best_165_02640.dat: w=253, l=127, d=0
saves/mu-t5-6/best_005_00100.dat: w=250, l=130, d=0
saves/mu-t5-6/best_160_02560.dat: w=236, l=144, d=0
saves/mu-t5-6/best_135_02310.dat: w=220, l=160, d=0
如你所见,最佳模型是训练初期存储的模型,这可能表明了收敛性不良(因为我并没有调节很多超参数)。
图 20.8 展示了模型胜率与模型索引的关系图,这个图与策略损失有很大关联,这是可以理解的,因为较低的策略损失应当带来更好的游戏表现:
图 20.8:训练过程中存储的最佳模型的胜率
MuZero 与 Atari
在我们的示例中,我们使用了“连接 4”这款两人棋盘游戏,但我们不应忽视 MuZero 的泛化能力(使用隐藏状态),使得它能够应用于更经典的强化学习场景。在 Schrittwieser 等人[Sch+20]的论文中,作者成功地将这一方法应用于 57 款 Atari 游戏。当然,这个方法需要针对这些场景进行调优和适配,但核心思想是相同的。这部分留给你作为练习,自己尝试。
总结
在这一章节中,我们实现了由 DeepMind 创建的 AlphaGo Zero 和 MuZero 基于模型的方法,这些方法旨在解决棋类游戏。该方法的核心思想是通过自我对弈来提升智能体的实力,而无需依赖于人类游戏或其他数据源的先验知识。这类方法在多个领域具有实际应用,如医疗(蛋白质折叠)、金融和能源管理。在下一章中,我们将讨论另一种实际 RL 方向:离散优化问题,它在各种现实问题中发挥着重要作用,从调度优化到蛋白质折叠。
加入我们的 Discord 社区
与其他用户、深度学习专家以及作者本人一起阅读本书。提出问题,为其他读者提供解决方案,通过“问我任何问题”环节与作者互动,还有更多内容。扫描二维码或访问链接加入社区。packt.link/rl
第二十一章:离散优化中的强化学习(RL)
深度强化学习(RL)的普遍看法是它主要用于玩游戏。考虑到历史上该领域的首次成功是在 2015 年由 DeepMind 在雅达利游戏套件上取得的(deepmind.com/research/dqn/),这并不令人惊讶。雅达利基准测试套件对于 RL 问题非常成功,直到现在,许多研究论文仍然用它来展示他们方法的效率。随着 RL 领域的发展,经典的 53 款雅达利游戏逐渐变得越来越不具挑战性(在撰写本文时,几乎所有游戏都已被超人级精度解决),研究人员正在转向更复杂的游戏,如《星际争霸》和《Dota 2》。
这种观点,尤其是在媒体中较为常见,是我在本书中试图加以平衡的。我通过将雅达利游戏与其他领域的实例结合起来,包括股票交易、自然语言处理(NLP)问题、网页导航自动化、连续控制、棋盘游戏和机器人技术,来对其进行补充。事实上,RL 非常灵活的马尔可夫决策过程(MDP)模型潜在地可以应用于各种领域;计算机游戏只是复杂决策的一种便捷且引人注目的示例。
在本章中,我们将探索 RL 应用中的一个新领域:离散优化(这是一门研究离散结构上的优化问题的数学分支),通过著名的魔方谜题来展示。我尝试提供对 UCI 研究员 McAleer 等人撰写的论文《无人工知识解决魔方》的详细描述[McA+18]。此外,我们还将介绍我对论文中所述方法的实现(该实现位于本书 GitHub 仓库的 Chapter21 目录中),并讨论改进该方法的方向。我将论文方法的描述与我版本中的代码片段结合起来,以通过具体实现来说明这些概念。
更具体地说,在本章中,我们将:
-
简要讨论离散优化的基础知识
-
逐步讲解 McAleer 等人[McA+18]应用 RL 方法解决魔方优化问题的过程
-
探讨我在尝试重现论文结果过程中所做的实验以及未来方法改进的方向
让我们从魔方和离散优化的一般概述开始。
魔方与离散优化
我相信你知道魔方是什么,因此我不会过多介绍这个谜题的通用描述(en.wikipedia.org/wiki/Rubik\%27s_Cube),而是专注于它与数学和计算机科学的联系。
如果没有明确说明,"魔方"指的是 3 × 3 × 3 经典的鲁比克魔方。基于原始的 3 × 3 × 3 谜题有很多变种,但它们仍然远不如经典版本流行。
尽管从机械原理和任务本身来看,魔方相对简单,但在所有通过旋转面实现的变换中,魔方却是一个相当棘手的物体。经过计算,总共通过旋转魔方可以到达约 4.33 ⋅ 10¹⁹个不同的状态。这些只是通过旋转魔方能到达的状态,而不需要拆解魔方;如果将魔方拆解后重新组装,最终可以获得多出 12 倍的状态,总数约为 5.19 ⋅ 10²⁰,但这些“额外”状态使得魔方在不拆解的情况下无法解决。
所有这些状态通过魔方面的旋转密切交织在一起。例如,如果我们在某个状态下将左侧顺时针旋转,则会到达一个状态,在该状态下,逆时针旋转同一面会取消变换的效果,并恢复到原始状态。
但是,如果我们连续三次进行左侧顺时针旋转,那么返回到原始状态的最短路径只需进行一次左侧顺时针旋转,而不是三次逆时针旋转(虽然逆时针旋转也是可能的,但并不是最优的)。由于魔方有 6 条边,每条边可以旋转 2 个方向,因此总共有 12 种可能的旋转。有时,半圈旋转(即两次连续的同向旋转)也被视为不同的旋转,但为了简便起见,我们将其视为魔方的两种不同变换。
在数学中,有几个领域研究这类对象。其中之一是抽象代数,这是数学中一个非常广泛的分支,研究带有运算的抽象对象集合。从这些角度来看,鲁比克魔方是一个相当复杂的群体(en.wikipedia.org/wiki/Group_theory),具有许多有趣的性质。
魔方不仅仅是状态和变换,它是一个谜题,主要目标是找到一个旋转序列,以解决魔方作为最终目标。这类问题通过组合优化进行研究,组合优化是应用数学和理论计算机科学的一个子领域。这个学科有很多具有高实际价值的著名问题,例如:
-
旅行商问题 (
en.wikipedia.org/wiki/Travelling_salesman_problem):在图中找到最短的闭合路径 -
蛋白质折叠模拟 (
en.wikipedia.org/wiki/Protein_folding):寻找蛋白质的可能三维结构 -
资源分配:如何在消费者之间分配固定的资源集,以达到最佳目标
这些问题的共同点是状态空间巨大,单纯通过检查所有可能的组合来找到最佳解是不切实际的。我们的“玩具魔方问题”也属于同类问题,因为 4.33 ⋅ 10¹⁹ 的状态空间使得暴力破解方法非常不实用。
最优性与上帝之数
使得组合优化问题棘手的原因在于,我们并不是在寻找任何解法;我们实际上关注的是问题的最优解。那么,二者有什么区别呢?魔方发明之后,人们就知道如何达到目标状态(但 Ernő Rubik 花了大约一个月的时间才弄明白他自己发明的魔方的第一个解法,这应该是一次令人沮丧的经历)。如今,有很多不同的魔方解法或方案:初学者方法(逐层法)、Jessica Fridrich 方法(在速解者中非常流行)等。
所有这些方法的差异在于所需的步骤数。例如,一个非常简单的初学者方法需要大约 100 次旋转才能解出魔方,只需记住 5…7 种旋转序列。相比之下,目前的世界纪录是在 3.13 秒内解出魔方,这需要更少的步骤,但需要记住更多的旋转序列。Fridrich 方法的平均步数大约是 55 步,但你需要熟悉 120 种不同的旋转序列。当然,关键问题是:解决任何给定魔方状态的最短动作序列是什么?令人惊讶的是,魔方发明 50 年后,人类仍然不知道这个问题的完整答案。直到 2010 年,谷歌的研究人员才证明,解决任何魔方状态所需的最小步数是 20 步。这个数字也被称为上帝之数(不要与自然界中到处可见的“黄金比例”或“神圣比例”混淆)。当然,平均而言,最优解的步数更短,因为只有少数几个状态需要 20 步,而某个单一状态根本不需要任何步骤(即已解状态)。这个结果仅证明了最小步数;它并没有找到具体的解法。如何为任何给定的状态找到最优解仍然是一个悬而未决的问题。
魔方求解方法
在 McAleer 等人发表论文之前,解决魔方问题的研究方向主要有两个:
-
通过使用群论,可以显著减少需要检查的状态空间。使用这种方法的最流行的解决方案之一是 Kociemba 算法(
en.wikipedia.org/wiki/Optimal_solutions_for_Rubik\%27s_Cube#Kociemba’s_algorithm)。 -
通过使用暴力搜索并辅以人工编写的启发式方法,我们可以将搜索引导至最有前景的方向。一个生动的例子是 Korf 的算法(
en.wikipedia.org/wiki/Optimal_solutions_for_Rubik\%27s_Cube#Korf’s_algorithm),它使用 A* 搜索和一个庞大的模式数据库来排除不良的方向。
McAleer 等人 [McA+18] 提出了第三种方法(称为自学迭代,或 ADI):通过对大量随机打乱的魔方进行神经网络(NN)训练,可以得到一个策略,该策略会指引我们朝着解决状态前进。该训练不依赖于领域的先验知识;所需的唯一条件是魔方本身(不是物理魔方,而是其计算机模型)。这与前两种方法形成对比,后者需要大量的领域知识并付出人力将其实现为计算机代码。
这种方法与我们在前一章讨论的 AlphaGo Zero 方法有很多相似之处:我们需要一个环境模型,并使用蒙特卡洛树搜索(MCTS)来避免对完整状态空间的探索。
在随后的章节中,我们将详细介绍这一方法;我们将从数据表示开始。在我们的魔方问题中,有两个实体需要被编码:动作和状态。
动作
动作是我们可以从任何给定魔方状态执行的旋转,如前所述,我们总共有 12 个动作。对于每一面,我们有两个不同的动作,分别对应于该面的顺时针和逆时针旋转(90^∘ 或 −90^∘)。一个小但非常重要的细节是,旋转是从希望的面朝向你的位置执行的。对于前面来说,这一点显而易见,但对于后面来说,由于旋转的镜像特性,可能会产生混淆。
动作的名称取自我们旋转的魔方面:左、右、上、下、前和后。面名称的第一个字母被用来表示。例如,右侧顺时针旋转的动作命名为 R。逆时针旋转有不同的表示方法;有时用撇号(R′)、小写字母(r)或波浪号(R̃)来表示。前两种表示方法在计算机代码中不太实用,因此在我的实现中,我使用小写字母表示逆时针旋转。对于右侧,我们有两个动作:R 和 r;左侧也有两个动作:L 和 l,依此类推。
在我的代码中,动作空间是通过 Python 枚举在 libcube/cubes/cube3x3.py 文件中的 Action 类实现的,其中每个动作都映射为唯一的整数值:
class Action(enum.Enum):
R = 0
L = 1
T = 2
D = 3
F = 4
B = 5
r = 6
l = 7
t = 8
d = 9
f = 10
b = 11
此外,我们描述了一个包含反向动作的字典:
_inverse_action = {
Action.R: Action.r, Action.r: Action.R,
Action.L: Action.l, Action.l: Action.L,
Action.T: Action.t, Action.t: Action.T,
Action.D: Action.d, Action.d: Action.D,
Action.F: Action.f, Action.f: Action.F,
Action.B: Action.b, Action.b: Action.B,
}
状态
状态是立方体上彩色贴纸的特定配置,正如之前讨论的那样,我们的状态空间非常大(4.33 ⋅ 10¹⁹ 种不同状态)。但是,状态的数量并不是我们面临的唯一复杂性;此外,在选择特定状态表示方式时,我们有不同的目标希望达成:
-
避免冗余:在极端情况下,我们可以仅通过记录每一面上每个贴纸的颜色来表示立方体的状态。但如果仅计算这种组合的数量,我们得到的是 6^(6⋅8) = 6⁴⁸ ≈ 2.25 ⋅ 10³⁷,这比我们立方体的状态空间大小要大得多,这意味着这种表示方式具有高度冗余;例如,它允许立方体的所有面都有相同的颜色(除了中心的小立方体)。如果你想知道我是如何得到 6^(6⋅8) 的,这很简单:我们有六个面,每个面有八个小立方体(我们不算中心立方体),所以我们总共有 48 个贴纸,每个贴纸可以涂成六种颜色之一。
-
内存效率:如你即将看到的,在训练过程中,尤其是在模型应用期间,我们将需要在计算机内存中保持大量不同的立方体状态,这可能会影响过程的性能。因此,我们希望表示方式尽可能紧凑。
-
变换的性能:另一方面,我们需要实现所有应用于状态的操作,这些操作需要快速执行。如果我们的表示方式在内存上非常紧凑(例如使用位编码),但每次旋转立方体的面时都需要进行冗长的解包过程,那么我们的训练可能会变得过于缓慢。
-
神经网络友好性:并不是每种数据表示方式都同样适合作为神经网络的输入。这不仅在我们的案例中成立,在机器学习中普遍如此。例如,在自然语言处理(NLP)中,常用词袋模型或词嵌入;在计算机视觉中,图像从 JPEG 解码为原始像素;随机森林则要求数据经过大量特征工程;等等。
在论文中,立方体的每个状态都表示为一个 20 × 24 的张量,采用 one-hot 编码。为了理解这是如何实现的,以及为什么它具有这种形状,让我们从论文中所示的图 21.1 开始:
图 21.1:我们需要在立方体上跟踪的贴纸被标记为较浅的颜色
在这里,浅色标记了我们需要追踪的块的贴纸;其他贴纸(以较深的颜色显示)是冗余的,无需追踪。如你所知,立方体由三种类型的块组成:8 个角块,每个有 3 个贴纸;12 个侧块,每个有 2 个贴纸;以及 6 个中心块,每个有 1 个贴纸。乍一看,可能不太明显,但中心块完全不需要追踪,因为它们不能改变相对位置,只能旋转。因此,关于中心块,我们只需要就立方体的对齐方式(立方体在空间中的方向)达成一致并保持一致。
在我的实现中,白色面始终在顶部,前面是红色,左面是绿色,依此类推。这样使得我们的状态具有旋转不变性,基本上意味着整个立方体的所有可能旋转被视为相同的状态。
由于中心块完全不被追踪,在图中,它们被标记为较深的颜色。那么剩下的块呢?显然,每种类型的块(角块或侧块)都有其独特的颜色组合。例如,我的方向(白色在顶部,红色在前面,依此类推)下,组装后的立方体中,左上角的角块正对我们,颜色是绿色、白色和红色。没有其他角块有这些颜色组合(如有疑问请检查)。侧块也是如此。
由于这个原因,要找到某个特定角块的位置,我们只需要知道其中一个贴纸的位置。选择哪个贴纸完全是任意的,但一旦选择了,就必须坚持下去。如前图所示,我们追踪来自顶部的八个贴纸,来自底部的八个贴纸,以及四个额外的侧面贴纸:两个在前面,两个在后面。这样我们就有了 20 个需要追踪的贴纸。
现在,让我们讨论一下张量维度中的 24 是怎么来的。总的来说,我们有 20 个不同的贴纸需要追踪,那么它们在哪些位置可能会出现,取决于立方体的变换?这取决于我们正在追踪的块的类型。我们从角块开始讲起。总共有八个角块,立方体的变换可以把它们重新排列成任何顺序。所以,任何特定的角块都可以出现在八个可能的角位置中。
此外,每个角块都可以旋转,所以我们的“绿色、白色和红色”角块可能有三种不同的方向:
-
白色在顶部,绿色在左侧,红色在前面
-
绿色在顶部,红色在左侧,白色在前面
-
红色在顶部,白色在左侧,绿色在前面
因此,为了准确指示角块的定位和方向,我们有 8 × 3 = 24 种不同的组合。对于 12 个边块,它们只有两个贴纸,因此只有两种可能的方向,这同样给我们提供了 24 种组合,但它们来自不同的计算:12 × 2 = 24。最后,我们有 20 个立方体小块需要跟踪,8 个角块和 12 个边块,每个小块都有 24 个可能的状态。
一种非常流行的将此类数据输入神经网络的方法是独热编码(one-hot encoding),即对象的具体位置为 1,其他位置填充为 0。这样我们最终得到的状态表示是一个形状为 20 × 24 的张量。
从冗余度的角度来看,这种表示法与总状态空间更加接近;可能的组合数量为 24²⁰ ≈ 4.02 ⋅ 10²⁷。它仍然大于立方体状态空间(可以说它大得多,因为 10⁸的因子非常大),但比编码每个贴纸的所有颜色要好。这种冗余来自于立方体变换的复杂特性;例如,不能仅旋转一个角块(或翻转一个边块),而使其他所有块保持不变。数学特性超出了本书的范围,但如果你有兴趣,我推荐亚历山大·弗雷(Alexander Frey)和大卫·辛格马斯特(David Singmaster)所著的《魔方数学手册》[FS20]。
你可能已经注意到,立方体状态的张量表示有一个显著的缺点:内存低效。实际上,通过将状态保持为 20 × 24 的浮点张量,我们使用了 4 × 20 × 24 = 1,920 字节的内存,这在训练过程中需要保持成千上万的状态以及在解立方体时需要保持百万个状态的情况下非常庞大(正如你稍后会了解的那样)。为了克服这个问题,在我的实现中,我使用了两种表示法:一种张量用于神经网络输入,另一种更紧凑的表示法则用于长期存储不同的状态。这种紧凑的状态被保存为一组列表,编码角块和边块的置换及其方向。这个表示法不仅更加节省内存(160 字节),而且在实现变换时也更为方便。
为了说明这一点,接下来是立方体 3 × 3 库 libcube/cubes/cube3x3.py 的部分内容,负责紧凑表示。
变量initial_state是立方体已解状态的编码。在其中,我们跟踪的角块和边块贴纸处于其原始位置,两个方向列表都设置为 0,表示立方体小块的初始方向:
State = collections.namedtuple("State", field_names=[
’corner_pos’, ’side_pos’, ’corner_ort’, ’side_ort’])
initial_state = State(corner_pos=tuple(range(8)), side_pos=tuple(range(12)),
corner_ort=tuple([0]*8), side_ort=tuple([0]*12))
立方体的变换有点复杂,包含了许多表格,记录了应用不同旋转后的立方体块重新排列。我不会把这段代码放在这里;如果你感兴趣,可以从 libcube/cubes/cube3x3.py 中的 transform(state, action) 函数开始。检查该代码的单元测试也可能会有所帮助。
除了动作、紧凑状态表示和变换外,模块 cube3x3.py 还包括一个将立方体状态的紧凑表示(作为名为 State 的元组)转换为张量形式的函数。这个功能由 encode_inplace() 方法提供。
另一个已实现的功能是通过应用 render() 函数将紧凑的状态渲染为人类友好的形式。这个功能对于调试立方体变换非常有用,但在训练代码中并未使用。
训练过程
现在你知道了如何将立方体的状态编码成 20 × 24 的张量,让我们来探索神经网络架构,理解它是如何训练的。
神经网络架构
图 21.2,来自 McAleer 等人的论文,展示了网络架构:
图 21.2:神经网络架构将观察(顶部)转化为动作和值(底部)
作为输入,它接受已经熟悉的立方体状态表示形式,作为一个 20 × 24 的张量,并输出两个结果:
-
策略是一个包含 12 个数字的向量,表示我们行动的概率分布。
-
值是一个标量,估计传递的状态的“好坏”。值的具体含义将在下一节中讨论。
在我的实现中,架构与论文中的完全一致,模型位于模块 libcube/model.py 中。在输入和输出之间,网络有多个全连接层,使用指数线性单元(ELU)激活函数,如论文中所讨论的:
class Net(nn.Module):
def __init__(self, input_shape, actions_count):
super(Net, self).__init__()
self.input_size = int(np.prod(input_shape))
self.body = nn.Sequential(
nn.Linear(self.input_size, 4096),
nn.ELU(),
nn.Linear(4096, 2048),
nn.ELU()
)
self.policy = nn.Sequential(
nn.Linear(2048, 512),
nn.ELU(),
nn.Linear(512, actions_count)
)
self.value = nn.Sequential(
nn.Linear(2048, 512),
nn.ELU(),
nn.Linear(512, 1)
)
def forward(self, batch, value_only=False):
x = batch.view((-1, self.input_size))
body_out = self.body(x)
value_out = self.value(body_out)
if value_only:
return value_out
policy_out = self.policy(body_out)
return policy_out, value_out
forward() 调用可以有两种模式:既可以获取策略和值,也可以在 value_only=True 时,仅获取值。这在只有值头部结果需要关注时可以节省一些计算。
训练
在这个网络中,策略告诉我们应该对状态应用什么变换,而值则估计状态的好坏。但是,大问题仍然存在:我们如何训练这个网络?
如前所述,论文中提出的训练方法称为自学迭代(ADI)。让我们来看看它的结构。我们从目标状态(已组装的立方体)开始,应用一系列预定义长度 N 的随机变换。这样我们就得到了一个包含 N 个状态的序列。
对于序列中的每个状态 s,我们执行以下过程:
-
对 s 应用所有可能的变换(共 12 种)。
-
将这 12 个状态传递给我们当前的神经网络,要求输出值。这为 s 的每个子状态提供了 12 个值。
-
s 的目标值计算公式为 y[v[i]] = maxa+R(A(s,a))),其中 A(s,a) 是对状态 s 执行动作 a 后的状态,R(s) 等于 1 如果 s 是目标状态,其他情况为 -1。
-
s 的目标策略使用相同的公式进行计算,但我们取的是 argmax 而非 max:y[p[i]] = arg maxa + R(A(s,a)))。这意味着我们的目标策略将在子状态的最大值位置上为 1,在其他所有位置上为 0。
这个过程如图 21.3 所示,取自论文。生成了一个混乱序列,x[0],x[1],…x[N],其中魔方 x[i] 被展开显示。对于这个状态 x[i],我们通过应用前述公式,从展开状态中为策略头和值头生成目标。
图 21.3:训练数据生成
使用这个过程,我们可以生成任何我们需要的训练数据。
模型应用
好的,假设我们已经使用刚才描述的过程训练好了模型。我们应该如何使用它来解决打乱的魔方呢?从网络的结构上看,你可能会想出一个明显的,但并不成功的方法:
-
将我们想要解决的魔方的当前状态输入模型。
-
从策略头获取最大动作(或从结果分布中采样)。
-
对魔方执行该动作。
-
重复该过程,直到达到已解决的状态。
理论上,这种方法应该可行,但在实践中,它存在一个严重的问题:它不可行!主要原因在于我们的模型质量。由于状态空间的庞大和神经网络的性质,我们无法训练出一个神经网络,在任何输入状态下都能准确地返回最优动作。我们的模型并不是直接告诉我们如何做才能得到已解决的状态,而是展示了我们应该探索的有前景的方向。这些方向可能会把我们带得更接近解决方案,但有时也可能会误导我们,因为这个特定状态在训练过程中从未见过。别忘了,状态空间有 4.33 ⋅ 10¹⁹ 个,即使使用每秒数十万个状态的图形处理单元(GPU)训练速度,经过一个月的训练,我们也只能看到状态空间中的一小部分,大约为 0.0000005%。因此,必须使用更复杂的方法。
有一类非常流行的方法,称为 MCTS,其中一种方法在上一章中已有介绍。这些方法有很多变种,但总体思路可以通过与众所周知的暴力搜索方法进行比较来描述,比如广度优先搜索(BFS)或深度优先搜索(DFS)。在 BFS 和 DFS 中,我们通过尝试所有可能的动作并探索从这些动作得到的所有状态,来对我们的状态空间进行穷举搜索。这种行为与之前描述的过程正好相反(当我们有某种东西可以告诉我们在每个状态下应该去哪里时)。但 MCTS 在这些极端之间提供了一种选择:我们想进行搜索,并且有一些关于我们应该去哪里的信息,但在某些情况下,这些信息可能不可靠、嘈杂,甚至完全错误。然而,有时这些信息能够帮助我们发现可能加速搜索过程的有前景的方向。
正如我提到的,MCTS 是一系列方法,它们在具体细节和特点上有所不同。在论文中,使用了一种叫做上置信界 1(Upper Confidence Bound 1)的方法。这种方法作用于树形结构,其中节点代表状态,边表示连接这些状态的动作。在大多数情况下,整个树是巨大的,因此我们不能尝试构建整个树,而只能构建其中的一小部分。
一开始,我们从一个只包含一个节点的树开始,这个节点就是我们当前的状态。在每一步的 MCTS 中,我们沿着树向下走,探索树中的某条路径,可能会遇到两种选择:
-
我们当前的节点是叶节点(我们还没有探索这个方向)
-
我们当前的节点位于树的中间,并且有子节点。
对于叶节点,我们通过对状态应用所有可能的动作来“扩展”它。所有结果状态都会被检查是否是目标状态(如果已找到已解的魔方的目标状态,我们的搜索就结束了)。叶节点状态会被传递到模型,并且来自值头和策略头的输出会被存储以供后续使用。
如果节点不是叶节点,我们就知道它的子节点(可达的状态),并且我们从网络中获得了值和策略的输出。因此,我们需要做出决定,选择应该跟随哪条路径(换句话说,选择哪一个动作更有可能被探索)。这个决策并非易事,这就是我们在本书中先前讲过的探索与利用问题。一方面,来自网络的策略告诉我们该怎么做。但如果它是错误的呢?这个问题可以通过探索周围的状态来解决,但我们不希望总是进行探索(因为状态空间是巨大的)。因此,我们应该保持平衡,这直接影响到搜索过程的性能和结果。
为了解决这个问题,对于每个状态,我们保持一个计数器,记录每个可能的动作(共有 12 个),每当该动作在搜索过程中被选择时,计数器会增加。为了决定跟随哪个动作,我们使用这个计数器;一个动作被采取得越多,它在未来被选择的可能性就越小。
此外,模型返回的值也被用于这个决策过程中。这个值作为当前状态的值与其子状态的最大值进行跟踪。这使得最有前景的路径(从模型的角度来看)能够从父状态中被看到。
总结来说,从非叶节点的树中选择的动作是通过以下公式来选择的:
这里,Ns[t]表示在状态 s[t]中选择动作 a 的次数。Ps[t]是模型为状态 s[t]返回的策略,Ws[t]是模型对于状态 s[t]在分支 a 下所有子状态的最大值。
这个过程会一直重复,直到找到解决方案或耗尽时间预算。为了加速这一过程,MCTS 通常以并行方式实现,由多个线程执行多个搜索。在这种情况下,可能会从 A[t]中减去一些额外的损失,以防止多个线程探索树的相同路径。
解决这个过程难题的最后一部分是,一旦我们到达目标状态,如何从 MCTS 树中获取解决方案。论文的作者尝试了两种方法:
-
初步方法:一旦我们遇到目标状态,我们就使用从根状态到目标状态的路径作为解决方案。
-
BFS 方法:在达到目标状态后,在 MCTS 树上执行 BFS,以找到从根节点到该状态的最短路径。
根据作者的说法,第二种方法比初步方法找到的解决方案更短,这并不令人惊讶,因为 MCTS 过程的随机性可能会在解决路径中引入循环。
结果
论文中发布的最终结果相当令人印象深刻。在一个配有三块 GPU 的机器上训练了 44 小时后,网络学会了解决魔方,达到了与人类设计的求解器相同的水平(有时甚至更好)。最终模型与前面提到的两种求解器进行了比较:Kociemba 两阶段求解器和 Korf。论文中提出的方法名为 DeepCube。
为了比较效率,所有方法使用了 640 个随机打乱的魔方。打乱的深度为 1,000 步。解决方案的时间限制为一小时,DeepCube 和 Kociemba 求解器都能在该时间限制内解决所有魔方。Kociemba 求解器非常快速,其中位解决时间仅为一秒,但由于该方法中硬编码规则的实现,它的解决方案并不总是最短的。
DeepCube 方法花费了更多的时间,中位数时间大约为 10 分钟,但它能够与 Kociemba 解法的解答长度相匹配,或者在 55% 的情况下表现得更好。从个人角度来看,55% 的表现并不足以证明神经网络在性能上有显著优势,但至少它们并不逊色。
在图 21.4 中,展示了所有求解器的解答长度分布。正如你所看到的,由于 Korf 求解器解决魔方所需的时间过长,它没有在 1,000 次混乱测试中进行比较。为了将 DeepCube 的表现与 Korf 求解器进行比较,创建了一个更简单的 15 步混乱测试集:
图 21.4:不同求解器找到的解答长度
代码大纲
现在你已经了解了一些背景,让我们切换到代码部分,代码位于书籍 GitHub 仓库的 Chapter21 目录中。在本节中,我将简要概述我的实现和关键设计决策,但在此之前,我必须强调关于代码的重要细节,以便设定正确的期望:
-
我不是一个研究人员,所以这段代码的最初目标只是重新实现论文中的方法。不幸的是,论文中关于超参数的细节非常少,所以我不得不做很多实验,尽管如此,我的结果与论文中发布的结果差异很大。
-
与此同时,我尽量将所有内容实现得更通用,以简化后续的实验。例如,魔方状态和变换的具体细节被抽象化,这使得我们可以通过添加新模块来实现更多类似于 3×3 魔方的谜题。在我的代码中,已实现了 2×2 和 3×3 魔方,但任何具有固定可预测动作集合的完全可观察环境都可以被实现并进行实验。具体细节将在本节稍后(在“魔方环境”小节中)给出。
-
代码的清晰性和简洁性被置于性能之前。当然,当有机会在不引入过多开销的情况下提高性能时,我会这么做。例如,仅仅通过将混乱魔方的生成和前向网络传递分开,训练过程的速度提高了五倍。但如果性能要求将一切重构为多 GPU 和多线程模式,我宁愿保持简单。一个很明确的例子是 MCTS 过程,通常会实现为多线程代码共享树结构。它通常可以加速数倍,但需要在进程之间进行复杂的同步。因此,我的 MCTS 版本是串行的,仅对批量搜索做了微小的优化。
总体而言,代码包含以下部分:
-
魔方环境,定义了观察空间、可能的动作以及状态到网络的准确表示。这个部分在 libcube/cubes 模块中实现。
-
神经网络部分,描述了我们将要训练的模型、训练样本的生成和训练循环。它包括训练工具 train.py 和模块 libcube/model.py。
-
立方体的求解器或搜索过程,包括求解器(solver.py)工具和实现 MCTS 的 libcube/mcts.py 模块。
-
各种工具被用来将其他部分粘合在一起,如包含超参数的配置文件和用于生成立方体问题集的工具。
立方体环境
正如你已经看到的,组合优化问题是相当庞大和多样的。即使是狭义的立方体类谜题,也包括了几十种变体。最流行的包括 2 × 2 × 2、3 × 3 × 3 和 4 × 4 × 4 的魔方,Square-1 和 Pyraminx(ruwix.com/twisty-puzzles/)。与此同时,本文中提出的方法是相当通用的,不依赖于先验领域知识、动作数量和状态空间大小。对问题的关键假设包括:
-
环境的状态需要是完全可观察的,观察结果需要能够区分不同的状态。这对魔方来说是成立的,因为我们可以看到所有面的状态,但对于大多数扑克牌变体来说,这不成立,例如我们看不到对手的牌。
-
动作的数量需要是离散且有限的。我们可以对魔方采取的动作数量是有限的,但如果我们的动作空间是“将方向盘旋转角度 α ∈ [−120^∘…120^∘]”,那么我们面对的将是一个不同的问题领域,正如你在涉及连续控制问题的章节中已经看到的那样。
-
我们需要有一个可靠的环境模型;换句话说,我们必须能够回答类似“将动作 a[i] 应用于状态 s[j] 后会得到什么结果?”这样的问题。如果没有这个,ADI 和 MCTS 都无法应用。这是一个强要求,对于大多数问题,我们没有这样的模型,或者其输出是相当嘈杂的。另一方面,在像国际象棋或围棋这样的游戏中,我们有这样的模型:游戏规则。
与此同时,正如我们在上一章(关于 MuZero 方法)中看到的,你可以使用神经网络来逼近模型,但代价是性能会降低。
-
此外,我们的领域是确定性的,因为对相同的状态应用相同的动作总是会得到相同的最终状态。反例可能是西洋双陆棋,每回合玩家投掷骰子来决定他们可能进行的步数。很可能,这种方法也可以推广到这种情况。
为了简化方法在与 3 × 3 立方体不同领域中的应用,所有具体的环境细节被移到单独的模块中,并通过抽象接口 CubeEnv 与其余代码进行通信,该接口在 libcube/cubes/_env.py 模块中进行了描述。让我们来看看它的接口。
如下代码片段所示,类的构造函数接受一组参数:
-
环境的名称。
-
环境状态的类型。
-
魔方的初始(已组装)状态实例。
-
检查特定状态是否表示已组装魔方的谓词函数。对于 3×3 魔方来说,这可能是多余的,因为我们可以直接将其与传递给
initial_state参数的初始状态进行比较;但对于 2×2 和 4×4 等魔方,可能有多个最终状态,因此需要单独的谓词函数来处理这种情况。 -
可以应用于状态的动作枚举。
-
转换函数,接受状态和动作,并返回结果状态。
-
逆函数,将每个动作映射到其逆操作。
-
渲染函数,用于以人类可读的形式表示状态。
-
编码状态张量的形状。
-
将紧凑状态表示编码为适合神经网络的形式的函数。
class CubeEnv:
def __init__(self, name, state_type, initial_state, is_goal_pred,
action_enum, transform_func, inverse_action_func,
render_func, encoded_shape, encode_func):
self.name = name
self._state_type = state_type
self.initial_state = initial_state
self._is_goal_pred = is_goal_pred
self.action_enum = action_enum
self._transform_func = transform_func
self._inverse_action_func = inverse_action_func
self._render_func = render_func
self.encoded_shape = encoded_shape
self._encode_func = encode_func
如你所见,魔方环境与 Gym API 不兼容;我故意使用这个例子来说明如何超越 Gym 的限制。
CubeEnv API 中的一些方法仅仅是构造函数传递的函数的包装器。这允许新的环境在一个单独的模块中实现,注册到环境注册表中,并为其余代码提供一致的接口:
def __repr__(self):
return "CubeEnv(%r)" % self.name
def is_goal(self, state):
assert isinstance(state, self._state_type)
return self._is_goal_pred(state)
def transform(self, state, action):
assert isinstance(state, self._state_type)
assert isinstance(action, self.action_enum)
return self._transform_func(state, action)
def inverse_action(self, action):
return self._inverse_action_func(action)
def render(self, state):
assert isinstance(state, self._state_type)
return self._render_func(state)
def encode_inplace(self, target, state):
assert isinstance(state, self._state_type)
return self._encode_func(target, state)
类中的所有其他方法提供基于这些原始操作的扩展统一功能。
方法sample_action()提供了随机选择一个动作的功能。如果传递了prev_action参数,我们会排除逆向动作,从而避免生成短循环,例如 R →r 或 L →l:
def sample_action(self, prev_action=None):
while True:
res = self.action_enum(random.randrange(len(self.action_enum)))
if prev_action is None or self.inverse_action(res) != prev_action:
return res
方法scramble()将一系列动作(作为参数传递)应用于魔方的初始状态,并返回最终状态:
def scramble(self, actions):
s = self.initial_state
for action in actions:
s = self.transform(s, action)
return s
方法scramble_cube()提供了随机打乱魔方的功能,并返回所有中间状态。如果return_inverse参数为 False,函数返回包含每一步打乱过程的(depth, state)元组列表。如果参数为 True,它返回一个包含三个值的元组:(depth, state, inv_action),这些值在某些情况下是必需的:
def scramble_cube(self, scrambles_count, return_inverse=False, include_initial=False):
assert isinstance(scrambles_count, int)
assert scrambles_count > 0
state = self.initial_state
result = []
if include_initial:
assert not return_inverse
result.append((1, state))
prev_action = None
for depth in range(scrambles_count):
action = self.sample_action(prev_action=prev_action)
state = self.transform(state, action)
prev_action = action
if return_inverse:
inv_action = self.inverse_action(action)
res = (depth+1, state, inv_action)
else:
res = (depth+1, state)
result.append(res)
return result
方法explore_states()实现了 ADI 的功能,并对给定的魔方状态应用所有可能的动作。返回值是一个元组,其中第一个列表包含扩展状态,第二个列表包含标记这些状态是否为目标状态的标志:
def explore_state(self, state):
res_states, res_flags = [], []
for action in self.action_enum:
new_state = self.transform(state, action)
is_init = self.is_goal(new_state)
res_states.append(new_state)
res_flags.append(is_init)
return res_states, res_flags
通过这种通用功能,类似的环境可以被实现并轻松地插入到现有的训练和测试方法中,只需要非常少的样板代码。作为示例,我提供了我在实验中使用的 2 × 2 × 2 立方体和 3 × 3 × 3 立方体。它们的内部实现位于 libcube/cubes/cube2x2.py 和 libcube/cubes/cube3x3.py,你可以将它们作为基础来实现你自己的此类环境。每个环境需要通过创建 CubeEnv 类的实例并将该实例传递给 libcube/cubes/_env.py 中定义的 register() 函数来注册自己。以下是来自 cube2x2.py 模块的相关代码:
_env.register(_env.CubeEnv(name="cube2x2", state_type=State, initial_state=initial_state,
is_goal_pred=is_initial, action_enum=Action,
transform_func=transform, inverse_action_func=inverse_action,
render_func=render, encoded_shape=encoded_shape,
encode_func=encode_inplace))
完成此步骤后,可以通过使用 libcube.cubes.get() 方法来获取立方体环境,该方法以环境名称作为参数。其余的代码仅使用 CubeEnv 类的公共接口,这使得代码与立方体类型无关,并简化了可扩展性。
训练
训练过程在工具 train.py 和模块 libcube/model.py 中实现,它是对论文中描述的训练过程的直接实现,有一个区别:该代码支持两种计算网络值头目标值的方法。方法之一与论文中描述的完全相同,另一种是我的修改,我将在后续部分详细解释。
为了简化实验并使结果可复现,所有训练的参数都在单独的 .ini 文件中指定,该文件提供了以下训练选项:
-
将要使用的环境名称;目前,cube2x2 和 cube3x3 可用。
-
运行的名称,在 TensorBoard 名称和目录中用于保存模型。
-
在 ADI 中将使用哪种目标值计算方法。我实现了两种方法:一种是论文中描述的,另一种是我的修改方法,从我的实验来看,这种方法具有更稳定的收敛性。
-
训练参数:批量大小、CUDA 使用、学习率、学习率衰减等。
你可以在仓库的 ini 文件夹中找到我的实验示例。在训练过程中,参数的 TensorBoard 指标会被写入 runs 文件夹。具有最佳损失值的模型会保存在 saves 目录中。
为了让你了解配置文件的样子,以下是 ini/cube2x2-paper-d200.ini,它定义了一个使用论文中的值计算方法和 200 步混合的 2 × 2 立方体实验:
[general]
cube_type=cube2x2
run_name=paper
[train]
cuda=True
lr=1e-5
batch_size=10000
scramble_depth=200
report_batches=10
checkpoint_batches=100
lr_decay=True
lr_decay_gamma=0.95
lr_decay_batches=1000
要开始训练,你需要将 .ini 文件传递给 train.py 工具;例如,以下是如何使用前述 .ini 文件来训练模型:
$ ./train.py -i ini/cube2x2-paper-d200.ini -n t1
额外的参数 -n 用于指定运行的名称,该名称将与 .ini 文件中的名称结合,作为 TensorBoard 系列的名称。
搜索过程
训练的结果是一个包含网络权重的模型文件。该文件可以用于使用 MCTS 求解魔方,MCTS 实现位于工具 solver.py 和模块 libcube/mcts.py 中。
求解工具非常灵活,可以在多种模式下使用:
-
通过传递 -p 选项来解决给定的一个打乱的魔方,作为逗号分隔的动作索引列表。例如,-p 1,6,1 表示通过应用第二个动作、然后第七个动作,最后再次应用第二个动作来打乱魔方。动作的具体含义依赖于环境,通过 -e 选项传递。你可以在魔方环境模块中找到动作及其索引。例如,2×2 魔方上,动作 1,6,1 表示 L → R′ → L 的变换。
-
从文本文件中读取排列(每行一个魔方),并解决它们。文件名通过 -i 选项传递。文件夹 cubes_tests 中有几个示例问题。你可以使用 gen_cubes.py 工具生成自己的随机问题集,该工具允许你设置随机种子、打乱深度以及其他选项。
-
生成一个随机的打乱,给定的深度并解决它。
-
通过增加复杂度(打乱深度)运行一系列测试,解决问题,并写入包含结果的 CSV 文件。通过传递 -o 选项启用此模式,它对于评估训练模型的质量非常有用,但完成这些操作可能需要大量时间。可选地,可以生成带有这些测试结果的图表。
在所有情况下,你需要通过 -e 选项传递环境名称,并通过 -m 选项传递模型的权重文件。此外,还有其他参数,允许你调整 MCTS 选项以及时间或搜索步数的限制。你可以在 solver.py 的代码中找到这些选项的名称。
实验结果
不幸的是,论文没有提供关于该方法的许多重要细节,如训练超参数、训练过程中魔方被打乱的深度以及获得的收敛性。为了填补这些空白,我尝试了不同的超参数值(.ini 文件可在 GitHub 仓库中找到),但我的结果与论文中发布的结果差异很大。我观察到,原始方法的训练收敛性非常不稳定。即使使用较小的学习率和较大的批量大小,训练最终仍会发散,值损失成分会呈指数增长。图 21.5 和图 21.6 展示了这种行为的例子(来自 2×2 环境):
图 21.5:在论文方法训练期间,值头预测的值
图 21.6:论文方法典型运行中的策略损失(左)和值损失(右)
在进行多次实验后,我得出结论,这种行为是由于方法中提出了错误的值目标。实际上,在公式 y[v[i]] = maxa + R(A(s,a))) 中,网络返回的值 vs 总是加到实际奖励 R(s) 上,即使是目标状态。这样,网络返回的实际值可能是任何值:−100、10⁶,或者 3.1415。这对于神经网络训练来说并不是一个理想的情况,尤其是当使用均方误差(MSE)目标时。
为了检查这一点,我通过为目标状态分配一个 0 目标,修改了目标值计算的方法:
可以通过在 .ini 文件中指定参数 value_targets_method 为 zero_goal_value 来启用这个目标,而不是默认的 value_targets_method=paper。
通过这个简单的修改,训练过程更快地收敛到了网络值头返回的稳定值。这种收敛的一个例子展示在图 21.7 和图 21.8 中:
图 21.7:训练期间值头预测的值
图 21.8:修改值计算后,策略损失(左)和值损失(右)
2 × 2 立方体
在论文中,作者报告了在一台配有三块 Titan Xp GPU 的机器上训练了 44 小时。在训练过程中,他们的模型查看了 80 亿个立方体状态。这些数字对应于训练速度 50,000 立方体/秒。我的实现使用单个 GTX 1080 Ti 显示为 15,000 立方体/秒,速度相当。因此,要在单个 GPU 上重复训练过程,我们需要等待近六天,这对于实验和超参数调优来说并不实用。
为了克服这一点,我实现了一个简单得多的 2 × 2 立方体环境,训练只需要一两个小时。为了重现我的训练,仓库中有两个 .ini 文件:
-
ini/cube2x2-paper-d200.ini:使用了论文中描述的值目标方法
-
ini/cube2x2-zero-goal-d200.ini: 目标值被设置为 0,用于目标状态
两种配置都使用了 10k 状态的批次和 200 的打乱深度,训练参数相同。训练结束后,使用这两种配置分别生成了两个模型:
-
论文方法:损失 0.032572
-
零目标方法:损失 0.012226
为了进行公平的比较,我为深度 1…50(共 1000 个测试立方体)生成了 20 个测试打乱,保存在 cubes_test/3ed 中,并在每种方法生成的最佳模型上运行了 solver.py 工具。对于每个测试打乱,搜索的限制被设置为 30,000。该工具生成了 CSV 文件(保存在 csvs/3ed 中),其中包含每个测试结果的详细信息。
我的实验表明,论文中描述的模型能够解决 55%的测试魔方,而带零目标修改的模型解决了 100%。两种模型在不同混乱深度下的结果如图 21.9 所示。在左图中,显示了解决魔方的比例。在右图中,显示了每个混乱深度所需的平均 MCTS 搜索步数。正如你所见,修改版本在找到解时需要显著(3 倍至 5 倍)更少的 MCTS 搜索次数,因此学到的策略更好。
图 21.9:已解 2 × 2 魔方的比例(左)和不同混乱深度下需要的平均 MCTS 搜索次数
最后,让我们检查一下找到的解的长度。在图 21.10 中,绘制了天真的方法和 BFS 解的长度。从这些图中可以看出,天真的解比 BFS 找到的解长得多(长了 10 倍)。这样的差异可能是未调优 MCTS 参数的迹象,这些参数是可以改进的。在天真的解中,零目标找到的解更短(这可能再次表明一个更好的策略)。
图 21.10:天真方法(左)和 BFS 方法(右)生成解法的比较
3 × 3 魔方
3 × 3 魔方模型的训练要重得多;我们这里仅仅是刚刚触及表面。但我有限的实验表明,零目标修改训练方法大大提高了训练稳定性和最终模型质量。训练大约需要 20 小时,因此进行大量实验需要时间和耐心。
我的结果没有论文中报告的那样亮眼:我能够获得的最佳模型可以解决最多12…15深度的魔方混乱问题,但在更复杂的问题上总是失败。可能,利用更多的中央处理单元(CPU)核心和并行 MCTS,这些数字可以得到改进。为了获取数据,搜索过程限制为 100k 步,并且每个混乱深度生成了五个随机混乱(可在 repo 中的 cubes_tests/3ed 找到)。但再次强调,修改版本显示出了更好的结果——使用论文方法训练的模型只能解决混乱深度为 9 的问题,而修改版本能够达到 13 的深度。
图 21.11 显示了论文中提出的方法和带零值目标的修改版本的解法率比较(左图)。图的右侧显示了平均 MCTS 搜索次数。
图 21.11:两种方法解决的 3 × 3 魔方比例(左)和平均 MCTS 搜索次数
图 21.12 显示了找到的最优解的长度。如前所述,天真搜索产生的解比 BFS 优化后的解长。BFS 的解长几乎完美地与混乱深度对齐:
图 21.12:3 × 3 魔方的朴素(左)与广度优先搜索(右)解法长度对比
理论上,在深度达到 20 后,它应该会饱和(因为“神数”是 20),但我的版本无法解决任何打乱深度超过 13 的魔方,因此很难判断。
进一步的改进和实验
有许多方向和方法可以尝试:
-
更多输入和网络工程:魔方是一个复杂的物体,因此简单的前馈神经网络可能不是最佳模型。可能,网络可以从卷积中大大受益。
-
训练中的振荡和不稳定性可能是强化学习中常见的步骤间相关性问题的信号。通常的方法是目标网络,我们使用旧版本的网络来获取自举值。
-
优先重放缓冲区可能有助于提高训练速度。
-
我的实验显示,样本的加权(与打乱深度成反比)有助于获得一个更好的策略,能够解决稍微打乱的魔方,但可能会减慢对更深状态的学习。可能,随着训练的进行,这种加权可以做成自适应的,使得它在后期训练阶段不那么激进。
-
可以将熵损失加入训练中,以规范化我们的策略。
-
2×2 立方体模型没有考虑到立方体没有中央小方块这一事实,因此整个立方体可以旋转。对于 2 × 2 立方体来说,这可能并不十分重要,因为状态空间较小,但对于 4 × 4 立方体来说,相同的观察将变得至关重要。
-
需要更多实验以获得更好的训练和蒙特卡罗树搜索(MCTS)参数。
摘要
在这一章中,我们讨论了离散优化问题——优化领域的一个子领域,处理像图或集合这样的离散结构。我们通过使用魔方这一众所周知但仍具挑战性的问题来检验强化学习的适用性。但总体来说,这一话题远比拼图问题广泛——同样的方法可以用于优化日程安排、最优路径规划和其他实际问题。
在本书的最后一章,我们将讨论强化学习中的多智能体问题。