第 9 章 多智能体强化学习
- 为什么普通的Q-learning在多智能体设置中变得不行
- 如何在多智能体中处理”维度诅咒”
- 如何实现多智能体Q-learning模型来感知其他智能体
- 如何使用平均场近似来改变MAQ-learning
- 在多智能体物理环境仿真和游戏中如何使用DQNs来控制数十个智能体
到目前为止,已经学习过的Q-learning,策略梯度和AC算法–已应用在一个环境中控制单智能体。但是如果是我们要控制能够与其他智能体交互的多智能体呢?最简单的例子就是一个二人游戏中每个玩家都是用强化学习实现的智能体。但是在其他情况下,我们想要建模上百个或上千个的智能体去互相交互,例如交通仿真。在这一章你将会学到如何把目前你所学到的应用到多智能体场景,通过使用平均场Q,平均场Q第一次提出是从一篇论文为Mean field Multi-Agent Reinforcement Learning, Yaodong Yang et al(2018)
9.1 从一个到多个的智能体
在游戏的案例中,环境可能包含其他我们无法控制的智能体,通常被称为NPCs。例如,在第8章我们训练了一个智能体去玩超级玛丽亚,这个游戏包含很多NPCs。这些NPCs通常被黑盒逻辑来控制,但是他们能与主玩家交互。从我们的DQN智能体的角度去看,这些NPCs只不过是模式,一种处于随着时间变化而变化的环境状态的模式。我们的DQN并没有直接意识到其他玩家的动作。这并不是a一个问题,因为这些NPCs 并不学习;他们有固定的策略。正如你们在本章看到的,有时我们不仅想要打败NPCs,而且实际上对众多交互学习智能体的行为进行建模(图9.1),这需要对你在本书中迄今为止学到的基本强化学习框架进行一些重构。
图9.1 在多智能体设定中,每个智能体的行为不止影响环境的演化,而且影响其他智能体的策略,这将导致高度动态的智能体交互。每个智能体从1到j 通过它们的策略会使用环境会产出的状态和奖励 来采取动作。然而每个智能体的策略将会影响所有其他智能体的策略。
例如,想象一下在一些环境中我们使用一个深度强化学习算法来直接控制多个交互智能体的动作。例如有些游戏可以允许多人组队,那我们可能想要开发一种能够玩多人一队来对抗其他队伍的算法。或者我们可能想要控制上百个仿真车的动作来对交通模式进行建模。又或者我们可能是经济学家,那在一个经济模型中我们想要对上百个智能体的行为进行建模。这是不同的情况,不是那种包含NPCs的情况,而是其他所有在学习的智能体,他们的学习会影响其他智能体。
最直接的解决方案就是扩展DQN到多智能体设定,为不同的智能体实例化多个DQNs(或者其他相似的算法),并且每个智能体看到环境就采取动作。如果我们尝试控制的智能体全部都使用相同的策略,在一些案例中是一个合理的假设(例如在多人游戏中每个智能体都是相同的),然后我们甚至能够重用单一的DQN(例如单一的参数集合)算法来对多个智能体建模。
这种方法被称为 independent Q-learning(IL-Q),并且效果也不错,但是它忽略了一个事实–智能体之间的交互作用会影响到每个智能体的决策能力。使用了IL-Q算法,每个智能体完全没意识到其他智能体在做什么,也不会意识到其他智能体的动作是如何影响自己本身。每个智能体仅仅获取到表示环境的状态信息,这些信息里包含了其他每个智能体的当前状态,但它本质上是把环境中其他智能体的活动看作是噪声,因为其他智能体的行为是,最多,仅仅是部分地预测(图9.2)。
图9.2 在independent Q-learning中,一个智能体并不直接察觉到其他智能体的动作,反而把他们当作是环境的一部分。Q-learning在单智能体设定中有收敛保证性,但在多智能体设定中使用了IL-Q就会失去这种保证性,因为其他智能体会使到环境具有非平稳性。
在我们到目前为止所学过的普通Q-learning中,环境中只有一个智能体,我们知道Q函数将收敛到最优值,所以我们将收敛于一个最优策略(从数学上看,它保证收敛)。这是因为在单智能体设定中,环境是平稳的,这意味着在给定状态下,给定动作的奖励分配总是相同的(图9.3)。在多智能体设定中这种平稳性被打破了,因为单个智能体获得的奖励不仅取决于其自身的行为,而且取决于其他代理的行为而变化。这是因为所有的智能体都是通过经验学习的强化学习智能体;他们的策略在随着环境的变化而不断变化。如果我们在这种非平稳环境中使用IL-Q,我们将失去收敛保证,这将显著影响IL-Q的性能。
图 9.3 在一个平稳的环境中,一个给定状态的期望值(即平均值)随时间的变化将保持不变(平稳性)。任何特定的状态转移都可能有一个随机分量,因此这是一个看似有噪声的时间序列,但时间序列的平均值是恒定的。在非平稳环境中,给定状态转换的期望值会随时间变化,这在这个时间序列中被描述为随时间变化的平均值或基线。Q函数试图学习状态动作的期望值,但只有在状态动作值具有稳态的情况下才能收敛,但在多智能体设定中,由于其他智能体策略的变化,预期的状态动作值会随着时间的推移而变化。
一个正常的Q函数是长这样的(图9.4)
Q
(
s
,
a
)
:
S
×
A
→
R
Q(s,a):S \times A \to R
Q(s,a):S×A→R 这是一个状态-动作对,对应一个奖励(实数)。
我们可以通过制作一个稍微复杂一点的Q函数来解决IL-Q的问题,该函数包含了其他智能体的行为知识,
Q
j
(
s
,
a
j
,
a
−
j
)
:
S
×
A
j
×
A
−
j
→
R
Q_j(s,a_j,a_{-j}):S \times A_j \times A_{-j} \to R
Qj(s,aj,a−j):S×Aj×A−j→R
这是第j个智能体的Q函数,它从状态、第j个智能体的动作和所有其他智能体的动作(表示为-j,发音为“not j”)这个元组来得到预测奖励(同样,只是一个实数)。众所周知,这类Q函数的收敛性保证了它最终会学习到最优值和策略函数,因此这个改进后的Q函数能够表现得更好
图 9.4 Q函数只需要输入状态就能得到状态动作值(Q值),然后为策略函数所用来产出一个动作。或者,我们可以直接训练一个策略函数,输入一个状态并返回一个动作的概率分布。
很不幸的是,当智能体数目很大时候,上述改进过的Q函数是难以处理的,因为联合动作空间 a − j a_{-j} a−j特别大,并且随着智能体数目增长而呈指数式增长。记得我们是如何对动作空间编码吗?我们使用一个长度等于动作数量的向量。如果我们想要对一个动作进行编码,我们把它看作一个ont-hot向量,除了与动作对应的位置为1,其他都为0。例如在在Gridworld环境中智能体有四个动作(上下左右),对应地,我们编码其为一个长度为4的向量,其中[1,0,0,0]可以编码为“向上”,[0,1,0,0]可以编码为“向下”,以此类推。
请记住,策略函数
π
(
s
)
:
S
→
A
π(s):S→A
π(s):S→A是接受一个状态并返回一个动作的函数。如果它是一个确定性策略,它将不得不返回其中一个one-hot向量;如果它是一个随机策略,它返回一个动作的概率分布,例如,[0.25,0.25,0.2,0.3]。那么上述的指数增长是指,如果我们想要明确地编码一个联合行动——例如,Gridworld中每个联合动作是由两个智能体的动作组成,每个智能体具有四种动作选择——意味着我们必须使用一个
4
2
4^2
42 = 16长度的ont-hot向量,而不是一个长度为4的向量。这是因为两个智能体之间有16种不同的动作组合,每个智能体有4个动作:[智能体1:动作1、智能体2:动作4]、[智能体1:动作3、智能体2:动作3]等等(参见图9.5)。
图9.5 如果每个智能体具有大小为4的动作空间(也就是说,用一个具有四位的one-hot向量表示),两个智能体的联合动作空间的大小就是
4
2
=
16
4^2=16
42=16,甚至N个智能体的动作空间大小就是
4
N
4^N
4N。这意味着联合动作空间大小的增长与智能体个数呈指数式变化。右边的图表示了当单个智能体的动作空间大小为2的时候,智能体个数与联合动作空间大小的关系。可以看到,到了25个智能体的时候,联合动作空间大小就是335554432位的one-hot向量,如此表示在计算上是不切实际的。
这里的概念我一直蒙圈,我以前以为表示一个智能体选择某个动作就用4位二进制表示,那么表示两个智能体的动作不就是8位吗,也没学过one-hot编码法(动作向量输出时只有一个元素为1,其他为0)。
现在我的理解是,如果某决策算法控制一个智能体输出一个动作(具有四种动作选择),那么四种动作可以用一维向量表示,分别表示为[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]。向量大小即动作数量,向量里哪个元素为1,该元素的位置顺序号就是被选择的第几个动作。
如果是控制相同的两个智能体的动作输出又怎么用这种one-hot表示呢?按照刚才的理解,向量长度就是可选动作的个数,两个智能体的联合动作空间是一个二维向量(就是个矩阵),完全无死角地表达两种智能体各自动作的所有选择。如果从矩阵角度理解,矩阵里元素 a i j a^{ij} aij为1,其余元素为0, a i j a^{ij} aij表示了第一个智能体选择了动作i ∈ \in ∈{1,2,3,4},而第二个智能体选择了动作j ∈ \in ∈{1,2,3,4}。显然该矩阵的空间大小表示了联合动作空间大小,两个智能体的联合动作空间里一共有 4 2 = 16 4^2=16 42=16种动作选择,该矩阵元素flatten(展平)的话应该用16个元素的一维向量表示。
如果是控制相同的三个智能体的动作输出呢?则是一个立方体形的三维向量,三个智能体的联合动作空间具有 4 3 = 64 4^3=64 43=64个元素,其余同理。
完美,结束,哈哈哈。
如果我们想对3个智能体的联合动作进行建模,我们必须使用一个 4 3 4^3 43 = 64长度的向量。所以,通常对于Gridworld这种环境,我们必须使用一个 4 N 4^N 4N长度的向量,其中N表示智能体的数量。对于任何环境,联合动作向量的大小将是 ⏐ A ⏐ N ⏐A⏐^Ν ⏐A⏐N,其中⏐A⏐是指动作空间的大小(即,离散动作的数量)。这是随着智能体数量而呈现出指数级增长的矢量,对于任何数量可观的智能体来说,这都是不切实际和难以解决的。呈指数式增长肯定是不好的,因为这意味着你的算法无法扩展。这个指数级般大小的联合动作空间是多智能体强化学习(MARL)带来的主要问题,也是我们将在本章中解决的问题。
9.2 邻域Q-learning
你可能想知道是否有一种更有效和更紧凑的方式来表示动作和联合动作,来绕过这个不切实际的庞大联合动作空间的问题。很不幸的是,使用一个更紧凑的编码来表示一个动作,这是一个不明确是否有效的方法。试想下你怎样才能够表示,明确地,使用一个数字去表示一组智能体所采取的动作,然后你会发现你不会做得比指数式增长的数字做得更好。
在这点上,MARL的路上似乎行不通,但我们可以通过对这个理想化的联合动作Q函数做一些近似来改变它。其中一个选项是要承认一个事实在大多数环境中,只有彼此接近的智能体才会对彼此产生显著影响。我们不一定需要为环境中所有智能体的联合动作进行建模;我们可以只对处于相同邻近范围内的智能体们的联合动作进行建模。在某种意义上,我们把完全的联合动作空间拆分为一组组重叠的子空间,并且只为这些相对小很多的子空间去计算Q值。我们可以称这种方法为邻域 Q-learning 或者是 子空间 Q-learning(图9.6)
图 9.6 在邻域MARL中,每个智能体都有一个视野范围(FOV)或邻域,并且它只能看到这个邻域内的其他智能体的行为。但是,它仍然可以获得有关环境的完整状态信息。
通过约束邻域的大小,我们阻止了联合动作空间的指数增长,直到我们为邻域设置的固定大小。如果我们有一个多智能体的Gridworld环境,每个智能体有4个动作,总共有100个智能体,那么完整的联合动作空间是
4
1
00
4^100
4100,这是一个棘手的大小;没有计算机可以计算(甚至存储)如此大的向量。然而,如果我们使用联合动作空间的子空间,并将每个子空间的大小(邻域)设置为3(故每个子空间的大小是
4
3
4^3
43 = 64),这是一个比单个智能体更大的向量,但它绝对是我们可以计算的东西。在这种情况下,如果我们计算智能体1的Q值,我们将找到距离智能体1最近的3个智能体,并为这3个智能体构建一个长度为64的联合动作的one-hot向量。这就是我们给出计算Q函数的方式(图9.7)。因此,对于这100个智能体中的每一个,我们将构建这些子空间的联合动作向量,并使用它们来计算每个智能体的Q值。然后我们会像以往一样地用这些Q值来采取行动。
图 9.7 第j个智能体的邻域Q函数的输入除了当前状态信息
S
t
S_t
St,还有其邻域(或者是视野范围内)的其他智能体们的联合动作向量,记为
a
−
j
a_{-j}
a−j。它输出了Q值,把Q值传递给将要选择动作去执行的策略函数。
我们尝试写一下它是如何工作的伪代码。
# listing 9.1 领域Q-learning的伪代码,第一部分
# 为所有智能体初始化动作
for j in agents: # 迭代整个环境中的所有智能体(存储在列表中)
state = environment.get_state() # 获取当前环境的状态信息
neighbors = get_neighbors(j,num=3) # 这个函数会找出离智能体j最近的三个智能体
joint_action = get_joint_action(neighbors) # 这个函数将返回智能体j的邻近智能体们的联合动作
q_values = Q(state,joint_action) # 给出状态和其邻近智能体们的联合动作下,获取智能体j每个动作的Q值。
j.action = policy(q_values) # 这个函数使用Q值返回一个离散动作
environment.take_action(j.action)
reward = environment.get_reward()
Listing 9.1中的伪代码反映了我们需要一个输入智能体j并返回离其最近的三个智能体的这样一个函数,然后我们需要另一个函数去使用这三个离得最近的智能体们去构建联合动作。在这一点上,我们遇上了另一个问题:在不知道其他智能体们的动作情况下我们如何构建联合动作?为了计算智能体j的Q值(从而能够采取动作),我们需要知道智能体非j的智能体们所采取的动作(我们使用-j符号来表示非智能体j的智能体们,仅限于找到的离得最近的智能体们中)。然而,为了算清楚非j的智能体们的动作,我们需要计算它们所有的Q值,接着我们似乎进入了一个无限循环,最终什么都得不到。
为了避免这个问题,我们通过一开始就随机地为所有智能体的动作初始化,使用了这些随机动作值,我们就能够计算出联合动作。但是如果我们这么做了,使用联合动作并不怎么有效,因为他们是随机的。在伪代码listing 9.2中,我们通过重复多运行几次(就是for m in range(M),其中M是一个较小的数,比如5)这个过程就可以解决这个问题。第一次循环中我们运行这个代码时候,联合行动将是随机的,但所有智能体们基于Q函数来选择动作,所以第二次循环中联合动作就稍微少了随机性,如果我们继续这样循环多几次,初始化的随机性将充分稀释,这样我们就可以在循环结束时候在真实环境中采取最终行动。
# listing 9.2 领域Q-learning的伪代码,第二部分
# 为所有智能体初始化动作
for m in range(M): # 通过迭代循环计算联合动作和Q值多几次就能稀释初始化的随机性能
for j in agents:
state = environment.get_state()
neighbors = get_neighbors(j,num=3)
joint_action = get_joint_action(neighbors)
q_values = Q(state,joint_action)
j.action = policy(q_values)
for j in agents: # 需要再一次迭代所有智能体们来执行前面循环结束之后所计算出的最终动作
environment.take_action(j.action)
reward = environment.get_reward()
Listings 9.1 和 9.2 表示了我们将要如何实现邻近Q-learning的基本结构,但是还有一个我们遗漏的细节,就是如何精确地为邻近的智能体们构建联合动作空间。我们利用线性代数的外积运算,从一组单个动作中建立一个联合动作。最简单的表示方法是将一个普通向量“提升”为一个矩阵。例如,我们有一个长度为4的向量,我们可以将它“提升”为一个4×1的矩阵。在PyTorch和numpy中,我们可以在张量上使用reshape方法来做到这一点,例如,torch.Tensor([1,0,0,0]).reshape (1,4)。当我们将两个矩阵相乘时,我们得到的结果取决于它们的维数和它们相乘的顺序。如果我们取一个A: 1×4矩阵,并用它乘以另一个矩阵B: 4×1,那么我们得到一个1×1的结果,这是一个标量(一个数字),这将是两个向量的内积(已提升为矩阵),因为最大的维数夹在两个单线态维数之间。而外积正好与这个相反,两个大维度在外面,两个单线态维度在内部,产生一个4×1⊗1×4 = 4×4矩阵。
如果我们在Gridworld环境中有两个智能体,它们的动作分别为[0,0,0,1](表示“右”)和[0,0,1,0](表示“左”),它们的联合动作可以通过计算这些向量的外积来获得。以下是如何用numpy来进行计算:
>>> np.array([[0,0,0,1]]).T @ np.array([[0,1,0,0]])
array([
[0,0,0,0],
[0,0,0,0],
[0,0,0,0],
[0,1,0,0]
])
结果是一个4×4矩阵,总共有16个元素,正如我们在前一节的讨论中所期望的那样。两个矩阵之间的外积结果的维数为dim (A) * dim (B),其中A和B是向量,“dim”是指向量的大小(维数)。外积是联合动作空间呈指数增长的原因。一般来说,我们需要我们的神经网络Q函数的输入为向量,所以外积给了我们一个矩阵结果,我们简单地把它展平为一个向量:
>>> z = np.array([[0,0,0,1]]).T @ np.array([[0,1,0,0]])
>>> z.flatten()
array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0])
希望你能理解到,领域Q-learning方法并不比普通的Q-learning复杂多少。我们只需要给它一个额外的输入,这额外的输入就是每个智能体的最近邻的几个智能体的联合动作向量。让我们通过解决一个真正的问题来弄懂细节。
9.3 一维伊辛模型
在本节中,我们将用MARL来解决一个真正的物理问题,这个问题是在20世纪20年代早期由物理学家威廉·伦茨和他的学生恩斯特·伊辛首次描述的。但首先,这是一节简短的物理课。物理学家们试图通过数学模型来理解铁等磁性材料的行为。你把一小片铁块拿在手中,铁块是由铁原子通过金属键结合在一起。原子由质子组成的原子核(带正电荷)、中子(无电荷)和电子(带负电荷)构成的。电子,像其他基本粒子一样,有一种叫做自旋的特性,它是被量子化的,以至于电子在任何时候只能有一个自旋向上或自旋向下(图9.8)。
图9.8 每个原子的原子核都是由这种电子所包围,电子是带负电荷的基本粒子。它们有一种叫做自旋的特性,它可以是自旋向上的,也可以是自旋向下的。由于它们是带电粒子,它们产生了磁场,它们的旋转方向决定了磁场的两极(北或南)的方向。
自旋性质可以被认为是顺时针或逆时针旋转的电子;但这样字面上的描述并不准确,但也达到我们的教学目的了。当一个带电物体旋转时,它会产生一个磁场,所以如果你拿一个橡胶气球,在地毯上摩擦给它带静电,然后让它旋转起来,你就会得到一个气球磁体(尽管是一个极弱的磁体)。电子同样地创建了一个磁场由于自旋性质和自身带电,所以电子真的是非常微小的磁体,因为所有的铁原子都有电子,那么如果铁块的所有的电子都在同一个方向上排列(即,所有自旋向上或所有自旋向下),整个铁块可以成为一个大磁铁。
物理学家们试图研究电子是如何“决定”自己排列的,以及铁的温度是如何影响这一过程。如果你加热一块磁铁,在某个点所排列的电子将开始随机交替它们的自旋,使得这个点失去了净磁场。物理学家知道,单个电子会产生磁场,一个微小的磁场会影响附近的电子。如果你曾经玩过两个棒状磁铁,你会注意到它们会自然地在相同方向上排列或在相反的方向上排斥。电子也是如此。这就能说通了为何电子们也会试图使它们自己保持相同的
自旋(图9.9)。
图 9.9 当电子被聚集在一起时,它们更倾向于自旋至相同的方向排列,因为如此排列会比当它们相反方向对齐排列的话花费更少的能量,而且所有的物理系统都倾向于较低的能量(在其他相同的情况下)。
不过,还有一个问题增加了复杂性。虽然单个电子有自己排列的倾向,但一个足够大的排列电子群实际上变得不稳定。这是因为随着排列电子数量的增加,磁场会增大,并对材料产生一些内部应变。所以真正发生的是电子们会形成集群,称为域,在这个域里面所有的电子都排列(向上或向下),但其他域也会形成。例如,可能有一个100个电子的域排列自旋向上,其旁边有另一个100个电子的域都排列自旋向下。因此,在非常局部的水平上,电子们通过排列来最小化它们的能量,但当排列太多而磁场变得太强时,系统的整体能量就会增长,导致电子只排列到相对较小的域中。
据推测,大块材料中数万亿个电子之间的相互作用导致电子复杂地组织成域,但很难对这么多的相互作用进行建模。因此,物理学家做出了一个简化的假设,即给定的电子只受其邻近的影响,这与我们在领域Q-learning中做出的假设完全相同(图9.10)。
图 9.10 这是一个高分辨率的伊辛模型,其中每个像素代表一个电子。较亮的像素是向上的,较暗的是向下的。你可以看到电子们组织成一个个的域,其中一个域内的所有电子都是对齐的,但是相邻域中的附近电子们相对于前者是反对齐的。这种组织减少了系统的能量。
值得注意的是,我们可以对许多电子们的行为进行建模,并通过多智能体强化学习来观察大规模的涌现组织。我们所需要做的就是把一个电子的能量解释为它的“奖励”。如果一个电子改变它的自旋与它附近的对齐,我们就会给它一个正奖励;如果它决定反对齐,我们就给它一个负奖励。当所有的电子都试图最大化他们的回报时,这就像试图最小化他们的能量一样,我们将得到与物理学家在使用基于能量的模型时得到的相同的结果。
你可能会疑惑,如果电子们都对齐方向的话就会得到了正向回报,那为什么这些被建模的电子们却没有所有地朝着同一方向排列,不像真正的磁铁那样形成域。首先我们的模型并不是完全现实的,但它最终形成了域,是因为有足够多的电子们,考虑这个过程有一些随机性,对于所有电子们是越来越不可能都对齐同一个方向(图9.11)。
图 9.11 这是对电子自旋的二维伊辛模型的描述,其中+为自旋向上,-为自旋向下。如图有一个域的电子们都是自旋向下的(用黑色高亮显示),这些电子被自旋向上的电子壳包围。
正如你们看到,我们也能够通过改变探索和利用的次数来对温度系统进行建模。请记住,探索包含了随机选择动作,同样高温也包含了随机变化。两者相当类似。
对电子自旋行为的行为进行建模可能似乎不重要,但用于电子们的基本建模技术可以用于解决遗传学、金融学、经济学、植物学和社会学等方面的问题。这也是测试MARL最简单的方法之一,所以这才是我们的主要目的。
为了创建一个伊辛模型,我们唯一需要做的事情就是创建一个二进制的网格,其中0表示自旋向下,1表示自旋向上。这个网格可以是任何维度的。我们可以有一个一维网格(一个向量),一个二维网格(一个矩阵),或者一些高阶的张量。
在接下来的几个代码清单中,我们将首先解决一维伊辛模型,因为它非常简单,因此我们不需要使用任何花哨的机制,如经验回放或分布式算法。我们甚至不会使用PyTorch的内置优化器——我们将在短短几行代码中手写出梯度下降算法。在清单9.3中,我们将定义一些函数来创建电子网格。
# listing 9.3 一维伊辛模型:创建表格和输出奖励
import numpy as np
import torch
from matplotlib import pyplot as plt
def init_grid(size=(10,)):
grid = torch.randn(*size)
grid[grid>0] = 1
grid[grid <= 0] = 0
grid = grid.bytes() # 把这些浮点数转换成字节,用字节来表示
return grid
def get_reward(s,a): # 该函数中s为邻域的智能体,然后对比这些智能体是否为智能体a,如果是就奖励更高。
r = -1
for i in s:
if i == a:
r+=0.9
r*=2
return r
我们在清单9.3中有两个函数;第一个函数中首先创建一堆从标准正态分布中抽样的数字,用以随机初始化一维网格(向量)。然后我们把所有的负数都设为0,所有的正数都设为1,我们将在网格中得到大致相同的1和0的数。我们可以使用matplotlib来可视化网格:
>>> size = (20,)
>>> grid = init_grid(size=size)
>>> grid
tensor([1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0],
dtype=torch.uint8)
>>> plt.imshow(np.expand_dims(grid,0))
如图9.12中所示,上述代码输出的1用光亮区域表示,0用暗区域表示。我们必须使用np.expand_dims(……)函数,通过增加一个单线态维数,使向量变成一个矩阵,因为plt.imshow只适用于矩阵或三维张量。
图 9.12 这是一维伊辛模型,代表电子们在一行中排列的自旋状态。
清单9.3中的第二个函数是我们的奖励函数。它的输入是一个元素为二进制数字的列表s和单个二进制数字a,然后比较s中与a的值匹配的数量。如果所有的值都匹配,奖励是最大的,如果没有一个匹配,奖励是负的。输入的s将是把邻域元素装进列表中。在本案例中,我们将使用两个离得最近的元素,所以给定的一个智能体中,它的邻域元素将会是网格中它所在位置的左边和右边的元素。如果一个智能体所在的位置是网格(向量)的末端,它的右边领域元素将会是网格中的第一个元素。这就使得原来的网格变成了一个圆形的网格。
网格中的每个元素(1或0)代表一个自旋向上或向下的电子。在强化学习术语中,电子们就是环境中的一个个单独的智能体。智能体们需要有值函数和策略,因此它们不能仅仅是一个二进制数。网格上的二进制数表示智能体的动作,选择自旋向上或自旋向下。因此,我们需要使用一个神经网络来我们的智能体们进行建模。我们将使用Q-learning的方法,而不是策略梯度方法。在清单9.4中,我们定义了一个函数来创建用于神经网络的参数向量。
# 清单9.4 一维伊辛模型:创建神经网络参数
# 此函数给神经网络创建了装进一个list的参数向量们
def gen_params(N,size):
ret = []
for i in range(N):
vec = torch.randn(size) / 10.
vec.requires_grad = True
ret.append(vec)
return ret
因为我们要使用一个神经网络来对Q函数建模,所以我们需要为它生成参数。在我们的例子中,我们将为每个智能体使用一个单独的神经网络,尽管这是不必要的;每个智能体都有相同的策略,所以我们可以重复使用相同的神经网络。我们这样做只是为了展示它是如何工作的;在后面的示例中,我们将对具有相同策略的智能体们使用一个共享的Q函数。
由于一维伊辛模型非常简单,我们将通过指定所有的矩阵乘法来手动编写神经网络,而不是使用PyTorch的内置层。我们需要构建接受一个状态向量和一个参数向量的Q函数,在函数体中,我们将参数向量分解成多个矩阵,这些矩阵是形成网络的每一层。
# 清单9.5 一维伊辛模型:定义Q函数
def qfunc(s,theta,layers=[(4,20),(20,2)],afn=torch.tanh):
l1n = layers[0]
l1s = np.prod(l1n) # 把layers里的第一个元组,并将这些数字相乘,得到的结果作为第一层使用的theta向量的子集
theta_1 = theta[0:l1s].reshape(l1n) # 将theta向量子集reshape为一个矩阵,作为神经网络的第一层
l2n = layers[1]
l2s = np.prod(l2n)
theta_2 = theta[l1s:l2s+l1s].reshape(l2n)
bias = torch.ones((1,theta_1.shape[1]))
l1 = s @ theta_1 + bias # 这是第一层的计算,s输入是一个(4,1)维度的联合动作向量
l1 = torch.nn.functional.elu(l1)
l2 = afn(l1 @ theta_2) # 我们还可以输入一个激活函数,用于最后一层;默认值是tanh,因为我们的奖励范围是[-1,1]。
return l2.flatten()
这是简单地用两层神经网络实现的Q函数(图9.13)。它要求输入一个状态向量s,即邻域状态信息的二进制向量,以及一个参数向量theta。它还需要keyword形的参数layers,这是一个形如[(s1,s2),(s3,s4)…]的列表,表示每个层的参数矩阵的形状。所有Q函数为每个可能的动作返回Q值;在这种情况下,它们用于向下或向上(两个动作)。例如,它可能返回向量[-1,1],表明做自旋向下这个动作的预期奖励是-1,而做自旋向上这个动作的预期奖励是+1。
图 9.13 智能体j的Q函数接受输入为一个参数向量θ,和智能体j的领域智能体们的一个one-hot编码的联合动作向量
a
−
j
a_{-j}
a−j。
对于多层神经网络使用单个参数向量的优点是易于存储所有参数到一个向量列表里。我们只需要让神经网络分解向量至layer矩阵中。我们使用tanh激活函数是因为它的输出区间为[-1,1],并且我们的奖励范围是在区间[-2,2]之间,所以一个+2的奖励将会倾向于把Q值输出至+1。然而,我们希望在后面的项目中能够复用Q函数,所以我们提供了激活函数作为一个可选的关键字参数afn。在清单9.6中,我们定义了一些辅助函数,以从环境(即网格)生成状态信息。
# 清单9.6 一维伊辛模型:获取环境的状态信息
def get_substate(b): # 获取一个二进制数字,然后转变其为one-hot编码向量如[0,1]
s = torch.zeros(2)
if b > 0: # 如果输入为0(自旋向下),动作向量就是为[1,0];其余的就是[0,1]
s[1] = 1
else:
s[0] = 1
return s
def joint_state(s): # s 是一个含两个元素的向量,s[0]是左边邻域的智能体,s[1]是右边邻域的智能体
s1_ = get_substate(s[0]) # 获取s中每个元素的动作向量
s2_ = get_substate(s[1])
ret = (s1_.reshape(2,1) @ s2_.reshape(1,2)).flatten() # 使用外积来创建联合动作空间,然后flatten至一个向量
return ret
清单9.6中的两个函数是我们需要为Q函数需要的状态信息而准备的辅助函数。get_substate函数接受一个二进制数字(0是向下,1是向上)的输入,然后转化其至一个one-hot编码的动作向量,那么对于[下,上]这样的动作空间,原来的二进制数字0就会变成[1,0],二进制数字1就会变成[0,1]。网格仅仅包含了一系列二进制数字来表示每个智能体的自旋方向,但是我们需要转变这些二进制数字至动作向量,然后把它们的外积结果作为一个联合动作向量,然后就可以传递给Q函数。在清单9.7中,我们将我们写过的函数的一部分放在一起,用以创建一个新的网格和一组参数向量,这些参数向量实际上包含了网格上的智能体们。
# 清单9.7 一维伊辛模型: 初始化网格
plt.figure(figsize=(8,5))
size = (20,) # 设置网格总大小为20长度的向量
hid_layer = 20 # 设置隐藏层的大小。我们的Q函数只不过一个两层的神经网络,所以只有一个隐藏层。
params = gen_params(size[0],4*hid_layer+hid_layer*2) # 创建一个装载参数向量们的列表,是近似Q函数的神经网络参数。
grid = init_grid(size=size)
grid_ = grid.clone() # 搞一份网格的副本(因为主训练网络中会把其清空)
print(grid)
plt.imshow(np.expand_dims(grid,0))
如果你运行清单9.7的代码,你应该能看到的结果类似如图9.14,但是不会是完全一样,毕竟是随机初始化的。
图 9.14 排列成一行的电子们的一维伊辛模型。
你会注意到,自旋方向是非常随机地分布在向上(1)和向下(0)之间。当我们训练我们的Q函数时,我们期望使它们的自旋方向在同一方向上对齐。它们可能不是都在同一方向对齐,但它们至少应该聚集在所有都是同一方向对齐的域中。既然我们已经定义了所有必要的函数,现在让我们进入主要的训练循环。
# 清单9.8 一维伊辛模型: 训练循环
epochs = 200
lr = 0.001 # 学习率
losses = [[] for i in range(size[0])] # 因为我们是在处理多智能体,故每个Q函数控制一个智能体,我们不得不保持追踪多个loss
for i in range(epochs):
for j in range(size[0]): # 迭代每个智能体
l = j - 1 if j - 1 >= 0 else size[0]-1 # 获取智能体j的左边邻居,如果是j是处于一开始的位置,左边邻居就取末端索引的智能体
r = j + 1 if j + 1 < size[0] else 0 # 获取智能体j的右边邻居,如果是j是处于末端的位置,右边邻居就取开端索引的智能体
state_ = grid[[l,r]] # state_是表示左右邻居智能体的自旋方向,用二进制数字表示。
state = joint_state(state_) # state是两位二进制的向量(能表示两个智能体4种联合动作之一),表示两个智能体的动作;把动作转化为one-hot联合动作向量。
qvals = qfunc(state.float().detach(),params[j],layers=[(4,hid_layer),(hid_layer,2)])
qmax = torch.argmax(qvals,dim=0).detach().item() # 策略是采取跟最高Q值相关的动作
action = int(qmax)
grid_[j] = action # 在临时网格副本grid_中采取动作action,只有当所有智能体都执行了动作后,我们才会将它们复制到主网格中。
reward = get_reward(state_.detach(),action)
with torch.no_grad(): # target是Q值向量,与所执行的动作action相关联的Q值被观察到的奖励所取代。
target = qvals.clone()
target[action] = reward
loss = torch.sum(torch.pow(qvals - target,2))
losses[j].append(loss.detach().numpy())
loss.backward()
with torch.no_grad(): # 手动梯度下降
params[j] = params[j] - lr * params[j].grad
params[j].requires_grad = True
with torch.no_grad(): # 复制临时grid_到主网格grid
grid.data = grid_.data
在主要的训练循环中,我们遍历所有20个智能体(它们代表电子们),对于每一个智能体,我们找到它的左右邻近的智能体,得到它们的联合动作向量,并使用它来计算自旋向下和自旋向上的两种可能动作的Q值。正如我们所设置的,一维伊辛模型不仅仅是一行网格单元,而是一个圆形的网格单元链,这样所有的智能体都有一个左右邻近的智能体(如图9.15)。
图 9.15 用二进制向量所表示的一维伊辛模型,但它实际上是一个圆形循环网格,因为我们把最左边的电子当作紧挨着最右边的电子。
每个智能体都有自己的相关参数向量的Q函数,所以每个智能体都由一个单独的深度Q网络控制(尽管它只是一个2层的神经网络,所以不是真正的“深度”)。同样,由于每个智能体都有相同的最优策略,即与它的邻居以相同的方式对齐,我们可以使用单个DQN来控制它们。我们将在后续的项目中使用这种方法,但我们认为如此直接地为每个智能体建模是很有用的。在其他环境中,智能体可能有不同的最优策略,你需要对每个策略使用单独的dqn。
我们已经简化了这个主要的训练函数,以避免干扰(图9.16)。首先,请注意,我们使用的策略是一个贪婪的策略。智能体每次都采取Q值最高的动作;并没有采取epsilon-greedy策略(这个策略有时候会执行随机动作)。一般来说,某种探索策略是必要的,但在一个如此简单的场景下,全程贪婪策略是仍然有效。在下一节中,我们将在一个正方形网格上求解一个二维化伊辛模型,在这种情况下,我们将使用一个softmax策略,其中softmax策略的温度参数会对物理电子环境所蕴含的真实物理温度进行建模。
图 9.16 这是一个针对主训练循环的线形图。对于每个智能体j,所对应的Q函数接受一个参数向量θ,智能体j的联合动作向量记为
a
−
j
a_{-j}
a−j。Q函数输出一个二维元素的Q值向量,该向量是策略函数的输入,策略函数就会输出一个one-hot向量(一个二进制位),然后将其存储在网格环境的副本(克隆)中。在所有智能体都选择了动作之后,副本网格将与主网格同步。为每个智能体生成奖励,并传递给损失函数,损失函数计算损失,并将损失反向传播到Q函数中,并最终传递到参数向量θ中进行更新。
目前我们所做的另一个简化是,上面代码的target Q值被设置为
r
t
+
1
r_{t+1}
rt+1(采取动作后的奖励)。但通常它是
r
t
+
1
+
γ
∗
V
(
S
t
+
1
)
r_{t+1} + γ * V(S_{t+1})
rt+1+γ∗V(St+1),其中最后一项是折扣因子
γ
γ
γ乘以采取动作后的状态价值。
V
(
S
t
+
1
)
V(S_{t+1})
V(St+1)是通过后续状态
S
t
+
1
S_{t+1}
St+1的最大Q值来计算的。这是我们在DQN章节中学过的自举方式。我们在本章后面的二维伊辛模型中会继续用到这样的自举方式。
如果你有跑过训练循环那一步,并且再次绘制出网格的话,你将会看到类似如下这样的东西:
>>> fig,ax = plt.subplots(2,1)
>>> for i in range(size[0]):
ax[0].scatter(np.arange(len(losses[i])),losses[i])
>>> print(grid,grid.sum())
>>> ax[1].imshow(np.expand_dims(grid,0))
图9.17中的第一个图是每个智能体(每种颜色代表不同的智能体)在每个Epoch的Loss的散点图。你可以看到,所有的Loss基本在30个Epoch左右呈下降趋势且进入平稳状态。当然,第二个图是我们的伊辛模型网格图,你可以看到它已经组织成两个域,它们都彼此完全对齐。中间较亮的部分是自旋向上(动作为1)的方向统一对齐的智能体,其余部分的智能体是自旋向下(动作为0)的方向上统一对齐。这比我们一开始的随机分布要好得多,所以我们的MARL算法在解决这个一维伊辛模型中毫无疑问地很有效。
图 9.17 上图:每个智能体的在训练时期的Loss。你可以看到它们都在下降,至少在30个Epoch左右。下图:奖励最大化(最小化能量)后的一维伊辛模型。你可以看到所有的电子聚集在一起,它们都是相同的方向。
现在我们已经成功地“解决”了一维伊辛模型。下一步,让我们通过二维伊辛模型来增加一些问题复杂性。除了解决我们所做的一些简化之外,我们还将引入一种新的邻域Q-learning方法,称为mean field Q-learning(平均场 Q-learning)。
9.4 平均场Q-learning和二维伊辛模型
在上面你才看过领域Q-learning这种方法是如何相当快速地解决一维伊辛模型。这是因为没有使用完全的联合动作空间(转换为one hot向量的话,起码要用
2
20
=
1048576
2^{20}=1048576
220=1048576个二进制位来表示),这是相当难以处理的,所以我们仅仅使用了智能体的左右旁边邻近的智能体的联合动作来表示。如此一来我们能把联合动作空间降低至
2
2
=
4
2^2=4
22=4个元素的联合动作向量,这才是我们能处理的范围。
在二维网格中,如果我们想要做同样的事情,并且只获取距离当前智能体最近的智能体们所形成的联合动作空间的话,那将是8个智能体的数量水平,也就是说联合动作空间会是
2
8
=
256
2^8=256
28=256个元素的向量。使用256个元素的向量进行计算绝对是可行的,但是在20×20网格中对400个智能体这样做将会变得太费时了。如果我们想使用三维伊辛模型,近邻的数量将是26,联合行动空间是
2
26
=
67108864
2^{26}=67108864
226=67108864;所以现在我们又回到了棘手的问题上了。
正如你所看到的,邻域方法比使用完整的联合动作空间要好得多。但是在更复杂的环境中,例如当邻近智能体的数量较多时,即采用紧邻智能体的联合动作空间也是巨大无比。显然,我们需要做一个更大的简化近似。请记住,邻域法在伊辛模型中之所以起作用的原因是因为电子的自旋方向受其最近邻智能体的磁场的影响最大。磁场强度与离场源距离的平方成比例地减小,因此忽略远处的电子是合理的。
我们可以通过注意到当两个磁体结合在一起时,产生的场是这两个磁体的一种总和来进行另一种近似(如图9.18)。我们可以把存在两个独立磁体的知识替换为存在一个磁体的近似值,该磁体的磁场是两个分量的总和。
图 9.18 左图:单个条形磁铁及其磁力线。回想一下,磁铁有两个磁极,通常被称为北(N)和南(S)。右图:把两个条形磁铁放在一起,它们的组合磁场会更复杂一些。当我们对电子自旋在2D或3D网格中的行为进行建模时,我们关心的是邻域中所有电子的贡献所产生的整体磁场;我们不需要知道每个电子的磁场是多少。
距离最近电子的单个磁场并不如它们的磁场总和重要,所以我们不需要把每个相邻电子的自旋方向信息输入到Q函数,而是给Q函数它们的自旋方向信息之和。例如,在1D网格中,如果左邻智能体的动作向量为[1,0](向下),而右邻智能体的动作向量为[0,1](向上),则总和将为[1,0]+[0,1]=[1,1]。
当数据被标准化至固定区间[0,1]内的时候,机器学习算法会表现得更优秀,部分原因是激活函数只在有限的输出范围(值域)内输出数据,而且太大或太小的输入的话输出结果有可能"饱和"了。例如,tanh函数在区间[-1,+1]中有一个值域(它可能输出的值的范围),所以如果你给它两个非常大但不相等的数字,它将输出为非常接近于1。因为计算机的精度有限,尽管两者(两个非常大但不相等的数字)是不同的输入,但两者的输出值都可能被舍入为1。所以如果我们将这些输入规范化为[-1,1]内,例如,tanh可能对一个输入返回0.5,另一个输入返回0.6,这是一个有意义的差异。
所以,我们不仅要把这些智能体的动作向量的总和输入至我们的Q函数,而且在输入到Q函数之前让这些智能体的动作向量的总和去除以所有元素之和,这将导致向量中的元素们规范化到[0,1]之间。例如,我们将计算[1,0] + [0,1] = [1,1]/2 = [0.5,0.5]。这个标准化向量之和为1,每个元素都在[0,1]之间,这提醒了你什么?概率分布。本质上,我们将要计算最近邻行为的概率分布,并将这个向量输入给我们的Q函数。
计算mean field动作向量
通常来说,我们通过以下函数来计算mean field动作向量 a − j = 1 N ∑ i = 0 N a i a_{-j} = \frac{1}{N}\sum_{i=0}^N a_i a−j=N1i=0∑Nai 其中 a − j a_{-j} a−j是表示智能体 j 的近邻智能体们的平均场的符号, a i a_i ai是表示智能体 i (智能体 j 的近邻智能体们之一)的动作向量。所以我们将智能体 j 的近邻智能体们(数量为N) 的动作向量们相加,然后除以近邻智能体数量N的大小来将结果归一化。如果你还是不太喜欢这种数学表达,你很快就会看到它在Python中是如何工作的。
这种方法被称为平均场近似,或者在我们的例子中,称为平均场Q-learning(MF-Q)。我们的想法是去计算每个电子周围的平均磁场,而不是提供每个近邻智能体的单独磁场(如图9.19)。这种方法的伟大之处在于,平均场向量只有单个动作向量那么长,无论我们的近邻智能体数量有多大或我们总共有多少个智能体。我们的环境可以是任意复杂和高维的,但它仍然很容易计算。
图 9.19 一对电子自旋方向的联合动作是它们两者动作向量之间的外积,这是一个含有四个元素的one-hot向量。如果不使用这种精确的联合动作向量,我们可以通过取这两个动作向量的平均值来近似它,从而得到所谓的平均场近似。对于在一起的两个电子,其中一个自旋向上和另一个自旋向下,平均场近似方法会让这两个电子在整体上换算为一个单独的“虚拟”电子,其自旋方向约等于为[0.5,0.5]。
让我们看看平均场Q-learning(MF-Q)在二维伊辛模型上是如何工作的。二维伊辛模型与一维版本完全相同,除了现在它是一个二维网格(即一个矩阵)。举例说下特殊位置的智能体的近邻情况如何计算,网格中左上角智能体的左邻情况是右上角的智能体,上邻情况将是左下角的智能体,因此网格实际上是包裹在一个球体的表面上的(图9.20)。
图 9.20 我们用二维网格来表示二维伊辛模型(即一个矩阵),但是我们将其设计为没有边界,并且出现在边界上的智能体实际上紧邻网格对面的智能体。因此,二维网格实际上是一个包裹在球体表面上的二维网格。
清单 9.9 平均场Q-learning:策略函数
from collections import deque # 使用队列作为经验回访来存储列表,因为它可以设置最大容量
from random import shuffle # 使用shuffle函数来对经验回访区的数据进行随机排列
def softmax_policy(qvals,temp=0.9): # 输入Q值向量给策略函数,然后返回一个动作,值为0(表示自旋向下)或者1(表示自旋向上)
soft = torch.exp(qvals/temp) / torch.sum(torch.exp(qvals/temp)) # 这是softmax函数定义
action = torch.multinomial(soft,1) # softmax函数转换Q值向量至动作的概率分布,我们使用multinomial函数根据该概率分布来随机选择动作。
return action
我们将用于二维伊辛模型的第一个新函数是softmax函数。之前在第二章中介绍策略函数的概念时有提及过这一点。策略函数是一个函数, π : S → A π:S→A π:S→A,从状态空间到动作空间。换句话说,给它一个状态向量,然后它返回一个要执行的动作。在第四章中,我们使用一个神经网络作为策略函数,并直接训练它输出最佳动作。在Q-learning中,我们有一个中间步骤,首先计算给定状态的动作值(Q值),然后我们使用这些动作值来决定采取哪个动作。所以在Q-learning中,输入Q值给策略函数并返回一个动作。
定义:softmax函数在数学形式上定义如下: P t ( a ) = e x p ( q t ( a ) / τ ) ∑ i = 1 n e x p ( q t ( a ) / τ ) P_t(a) = \frac{exp(q_t(a)/\tau)}{\sum_{i=1}^n exp(q_t(a)/\tau)} Pt(a)=∑i=1nexp(qt(a)/τ)exp(qt(a)/τ)
其中 P t ( a ) P_t(a) Pt(a)是动作的概率分布, q t ( a ) q_t(a) qt(a)是Q值向量, τ \tau τ是温度参数
提醒一下,softmax函数可以接受一个随便填写任意数字的向量来输入,然后“规范化”这个向量并输出为一个概率分布,这样所有的元素都是正的,这些元素加起来为1,转换后的每个元素与转换前的元素是成比例的(即,如果一个元素是原来向量中最大的,它将被分配最大的几率)。softmax函数有一个额外的输入,即温度参数temp,用希腊符号tau,即
τ
\tau
τ表示。
如果温度参数temp很大,它将最小化元素之间的几率差异性,如果温度参数很小,输入的差异性将被放大。我们做个简单对比,例如:
s
o
f
t
m
a
x
(
[
10
,
5
,
90
]
,
t
e
m
p
=
100
)
=
[
0.2394
,
0.2277
,
0.5328
]
softmax([10,5,90],temp=100)=[0.2394,0.2277,0.5328]
softmax([10,5,90],temp=100)=[0.2394,0.2277,0.5328]
和
s
o
f
t
m
a
x
(
[
10
,
5
,
90
]
,
t
e
m
p
=
0.1
)
=
[
0.0616
,
0.0521
,
0.8863
]
softmax([10,5,90],temp=0.1)=[0.0616,0.0521,0.8863]
softmax([10,5,90],temp=0.1)=[0.0616,0.0521,0.8863]
由此能看到,在较高的温度系数下,即使最后一个元素90比第二个最大元素10大了足足9倍,但由此产生的概率分布却赋予元素(为90)的概率为0.53,这仅仅大约是第二大元素(为10)的概率的两倍。当温度系数趋于无穷时,概率分布会是均匀分布(即所有元素的概率相等)。当温度系数接近0时,概率分布将变成退化分布(degenerate distribution),所有概率质量都在单点上。通过使用softmax作为策略函数,当
τ
\tau
τ→∞时,动作的选择则是完全随机的,当
τ
\tau
τ→0时,策略成为argmax函数(我们在上一节的一维伊辛模型中使用)。
这个参数被称为“温度”的原因是,softmax函数也在物理学中被用来模拟物理系统,比如电子系统的自旋方向,其中温度影响了物理系统的行为。在物理和机器学习之间有很多交叉影响。在物理学中,softmax这种现象被称为玻尔兹曼分布,它“作为某种状态的能量和系统温度的函数,给出了一个系统处于该状态的概率”(维基百科)。在一些强化学习的学术论文中,你可能会看到softmax策略被称为玻尔兹曼策略,但现在你知道其实这两者是一回事。
我们正在使用一个强化学习算法来解决一个物理问题,所以softmax函数的温度参数实际上对应于我们正在建模的电子系统的温度。如果我们将系统的温度参数设置为非常高,电子就会随机自旋,它们与近邻智能体对齐的倾向将被高温所克服。如果我们把温度参数设定得太低,电子就会被卡住,而且不太可能有什么变化。在清单9.10中,我们引入了一个函数来查找智能体的坐标,以及另一个函数来在新的二维环境中生成奖励。
# 清单 9.10 平均场Q-learning:坐标与奖励函数
def get_coords(grid,j): # 输入一个索引值,这个索引值来自于被展平的网格(一维数组),然后把索引值转化为二维坐标[x,y]作为返回结果
x = int(np.floor(j / grid.shape[0])) # 找出x坐标
y = int(j - x * grid.shape[0]) # 找出y坐标
return x,y
def get_reward_2d(action,action_mean): # 这是二维网格环境下的奖励函数
r = (action*(action_mean-action/2)).sum()/action.sum() # 奖励是基于智能体动作与平均场动作的不同程度
return torch.tanh(5 * r) # 使用tanh函数缩放奖励规模到[-1,+1]之间
使用[x,y]坐标来引用二维网格中的智能体是不方便的。所以我们通常引用某个智能体是通过展平二维网格至一个向量(理解为一维数组)来引用其索引,但我们需要能够将这个被"被展平的"索引转换为[x,y]坐标,这就是get_coonds函数要做的。get_reward_2d函数是我们二维网格环境下的奖励函数。它计算一个动作向量和一个平均场向量之间的差异性。例如,如果平均场向量为[0.25,0.75],当动作向量为[1,0]的奖励结果应低于当动作向量为[0,1]的情况:
>>> get_reward_2d(torch.Tensor([1,0]),torch.Tensor([0.25, 0.75]))
tensor(-0.8483)
>>> get_reward_2d(torch.Tensor([0,1]),torch.Tensor([0.25, 0.75]))
tensor(0.8483)
现在我们需要创建一个能找到某个智能体的最近邻智能体们的函数,然后计算这些最近邻智能体们的平均场向量。
# 清单 9.11 平均场Q-learning:计算平均场向量
def mean_action(grid,j):
x,y = get_coords(grid,j) # 转换向量化(就是一维化)的索引j至网格坐标[x,y],其中[0,0]是左上角。
action_mean = torch.zeros(2) # 这个用来装载我们需要叠加的动作均值向量
for i in [-1,0,1]: # 如此两个循环能让我们遍历智能体j的8个近邻智能体中的每一个
for k in [-1,0,1]:
if i == k == 0:
continue
x_,y_ = x + i, y + k
x_ = x_ if x_ >= 0 else grid.shape[0] - 1
y_ = y_ if y_ >= 0 else grid.shape[1] - 1
x_ = x_ if x_ < grid.shape[0] else 0
y_ = y_ if y_ < grid.shape[1] else 0
cur_n = grid[x_,y_]
s = get_substate(cur_n) # 转换每个近邻智能体的自旋方向至动作向量
action_mean += s
action_mean /= action_mean.sum() # 对动作向量进行标准化至一个概率分布
return action_mean
上述函数接受某个智能体的索引 j(一个整数,基于被展平网格的的索引)作为输入,并返回该智能体的8个最近的(周围的)近邻智能体们在二维网格上对其的平均作用。我们通过得到智能体的坐标来找到8个最近的邻居,比如坐标[5,5],然后我们只添加[x,y]的每个组合,其中x,y∈{0,1}。所以我们会做[5,5] + [1,0] = [6,5]和[5,5] + [-1,1] = [4,6],等等。
以下都是二维情况下所需要的所有附加函数们。例如我们将复用前面的init_grid函数和gen_params函数。现在让我们初始化网格和神经网络参数。
>>> size = (10,10)
>>> J = np.prod(size)
>>> hid_layer = 10
>>> layers = [(2,hid_layer),(hid_layer,2)]
>>> params = gen_params(1,2*hid_layer+hid_layer*2)
>>> grid = init_grid(size=size)
>>> grid_ = grid.clone()
>>> grid__ = grid.clone()
>>> plt.imshow(grid)
>>> print(grid.sum())
我们从一个10×10网格开始,这样会使算法运行得更快,但你应该勇于尝试使用更大的网格大小。你可以在图9.21中看到,自旋方向是随机分布在初始网格上的,所以我们希望在我们运行MARL算法之后,它看起来更有组织——我们希望看到对齐的电子们形成集群。我们已经将隐藏层的大小减少到10,以进一步减少计算成本。注意这次我们只生成一个参数向量;我们将使用单个DQN来控制所有的100个智能体,因为它们有相同的最优策略。然后我们会创建主网格的两个副本,为什么这么做?等我们进入训练循环这块,原因就会很清楚。
对于这个二维例子,我们将添加一些我们在一维情况中遗漏的复杂问题,而且这是一个更困难的问题。我们将使用经验回放机制来存储经验,并对这些经验进行mini-batch地训练。这么做就能减少了梯度中的方差,并稳定了训练。我们还将使用适当的目标Q值,
r
t
+
1
+
γ
∗
V
(
S
t
+
1
)
r_{t+1} + γ * V(S_{t+1})
rt+1+γ∗V(St+1),所以我们需要在每次迭代中计算两次Q值:一旦决定采取哪个操作,然后再次去获取
V
(
S
t
+
1
)
V(S_{t+1})
V(St+1)。在清单9.12中,我们看一下二维伊辛模型的主要训练循环模块。
图 9.21 这是一个随机初始化的二维伊辛模型。每个方格代表一个电子。浅色的网格正方形代表的电子是自旋向上的,而深色的正方形是自旋向下。
epochs = 75
lr = 0.0001
num_iter = 3 # mum_itr控制我们要迭代的次数,以消除平均场动作的初始随机性。
losses = [ [] for i in range(size[0])] # 每个智能体的loss都由列表存储,所有列表装载到losses中
replay_size = 50 # repaly_size限制了我们存储经验回放列表中的经验条目总数。
replay = deque(maxlen=replay_size) # 经验回放是一个队列结构,设定了replay_size这样的最大数量值。
batch_size = 10 # 将批处理大小设置为10,所以我们从经验回放中得到10个随机经验条目,并以此进行训练
gamma = 0.9 # 折扣因子
losses = [[] for i in range(J)]
for i in range(epochs):
act_means = torch.zeros((J,2)) # 为所有智能体存储其平均场动作(我严重怀疑这里是不是写错了,应该是torch.zeros(J) )
q_next = torch.zeros(J) # 若是采取动作后,为每个智能体存储后续状态的Q值
for m in range(num_iter): # 因为平均场都是随机初始化,我们迭代num_iter次来消除初始随机性
for j in range(J): # 迭代整个网格中的所有智能体
action_mean = mean_action(grid_,j).detach() # 获取第j个智能体的平均场动作
act_means[j] = action_mean.clone()
qvals = qfunc(action_mean.detach(),params[0],layers=layers) # 输入周围状态action_mean得出qvals
action = softmax_policy(qvals.detach(),temp=0.5) # 根据qvals,用策略函数获取动作action
grid__[get_coords(grid_,j)] = action # 选择了动作,把自旋方向状态存储在grid__中
q_next[j] = torch.max(qvals).detach() # 因为采取了动作,存放下一个状态的Q值
grid_.data = grid__.data # 已经迭代完所有智能体的状态,把grid__赋值给grid_
grid.data = grid_.data # 已经迭代若干次,消除了大部分随机性,正式赋值到grid中
actions = torch.stack([get_substate(a.item()) for a in grid.flatten()]) # 遍历grid中每个智能体,把其自旋方向转换至one-hot动作向量,放到actions存储
rewards = torch.stack([get_reward_2d(actions[j],act_means[j]) for j in range(J)]) # 遍历grid中每个智能体,输入其已经确定好的one-hot动作向量,并计算出其每个智能体对应的奖励,放到rewards存储
exp = (actions,rewards,act_means,q_next) # a,r,s为智能体所在的平均场,所有智能体采取动作后的后续状态的Q值
replay.append(exp)
shuffle(replay)
if len(replay) > batch_size: # 一旦经验回放缓冲区大小比批处理大小要大,就开始训练
ids = np.random.randint(low=0,high=len(replay),size=batch_size)
exps = [replay[idx] for idx in ids]
for j in range(J):
jacts = torch.stack([ex[0][j] for ex in exps]).detach()
jrewards = torch.stack([ex[1][j] for ex in exps]).detach()
jmeans = torch.stack([ex[2][j] for ex in exps]).detach()
vs = torch.stack([ex[3][j] for ex in exps]).detach()
qvals = torch.stack([ qfunc(jmeans[h].detach(),params[0],layers=layers) for h in range(batch_size)])
target = qvals.clone().detach()
target[:,torch.argmax(jacts,dim=1)] = jrewards + gamma * vs
loss = torch.sum(torch.pow(qvals - target.detach(),2))
losses[j].append(loss.item())
loss.backward()
with torch.no_grad():
params[0] = params[0] - lr * params[0].grad
params[0].requires_grad = True
虽然上述代码很多,但只是比一维伊辛模型稍微复杂一点而已。首先要指出的是,由于每个智能体的平均场依赖于它的邻域范围内的智能体,并且邻近智能体的自旋方向是随机初始化的,所以所有智能体的平均场一开始也将是随机的。为了帮助收敛,我们首先允许每个智能体根据这些随机平均场(s)来选择一个动作(a),并将动作存储在临时网格副本grid__中,如此我们就能在所有智能体都做出最终决定采取哪个动作之前,主网格不会改变。在每个智能体在grid__中做了一个初步的动作之后,我们把这些动作更新到第二个临时网格副grid_中,grid_是我们用来计算平均场的。在下一次迭代中,平均场将发生变化,我们允许智能体去更新它们的刚才初定的动作。就这样的迭代做多几次之后(由num_iter参数控制),就是为了让动作根据当前版本的Q函数稳定在一个接近最优价值附近。然后,我们更新到主网格,收集所有的动作、奖励、平均场和q_next值(
V
(
S
t
+
1
)
V(S_{t+1})
V(St+1)),并将它们添加到经验回放缓冲区中
一旦回放经验缓冲区的经验条目比batch_size这个参数大,我们就可以开始进行mini-batch训练。我们生成一个随机索引值的列表,并使用这些值采集经验回放缓冲区中的子集经验条目。然后我们像往常一样进行梯度下降。现在让我们运行训练循环这块代码,看看我们会得到什么。
>>> fig,ax = plt.subplots(2,1)
>>> ax[0].plot(np.array(losses).mean(axis=0))
>>> ax[1].imshow(grid)
成功了!从图9.22中可以看出,除三个电子外,所有电子(智能体)的自旋方向都朝着同一方向排列,这使系统的能量最小化(并使回报最大化)。这个loss图看起来部分很混乱,这是因为我们使用单个DQN来对每个智能体进行建模,因此当一个智能体试图与其邻近智能体对齐时候,但其邻近智能体却试图与另一个智能体对齐,这时候DQN有点像是在与自己对抗一样。可能会出现一些不稳定。
图 9.22 顶部图是DQN的loss图。loss看起来并没有收敛,但我们可以看到,它确实学会了最小化系统能量(最大化奖励),正如底部图所展示一样。
在下一节中,我们将通过解决两组智能体在游戏中相互竞争这种更困难的问题,将我们的多智能体强化学习技能提升到一个新的水平。
9.5 混合合作竞争的博弈
如果你把伊辛模型当作是多人博弈,那么它就是纯合作类型博弈,因为所有的智能体都有相同的目标,当他们都完成同一对齐方向时候他们的奖励就会最大化。相比之下,国际象棋就是一种纯粹的竞争博弈,因为当一个玩家赢了,另一个玩家就输了;这就是零和博弈。基于团队的博弈像篮球或足球,这种被称为混合合作竞争博弈,因为同一个团队的智能体们需要合作去最大化他们的回报,但当一个团队作为一个整体赢了的话,另一个团队肯定是输的,所以在团队水平上这是一个竞争性的博弈。
在本节中,我们将使用一个基于Gridworld的开源游戏,它是专门为测试合作、竞争或混合合作-竞争场景中的多智能体强化学习算法而设计的(图9.23)。在我们的案例中,我们将安装一个混合合作-竞争的博弈场景,里面有两支Gridworld的智能体团队,他们可以在网格中移动,也可以攻击对方团队的智能体们。每个智能体以“生命值”(HP)为1地开始,当它们受到攻击时,HP逐渐下降,直到达到0,此时智能体死亡并从网格中清除。智能体们会因攻击和杀死对方团队中的智能体们而得到奖励。
图9.23 图中是MAgent多人Gridworld游戏,里面是Gridworld中两个对立团队的智能体们的截图。各自团队的目标是杀死对方。
由于一个团队中的所有智能体们都有相同的目标,因此最优策略应该也是一样的,我们可以使用一个DQN来控制一个团队中的所有智能体们,并使用另一个不同的DQN来控制另一个团队中的智能体们。这基本上是两个DQNs之间的对抗,所以这将是一个完美的机会去尝试不同类型的神经网络,看看哪一种神经网络更好。为了简单起见,我们将为每个团队使用相同的DQN。
你需要按照自述文件页面上的说明从https://github.com/geek-ai/MAgent中安装MAgent库。从这一点开始,我们将假设你已经安装了它,并且你可以在Python环境中成功地运行import magent这行代码。
# 清单9.13 创建 MAgent 环境
import magent
import math
from scipy.spatial.distance import cityblock # 导入cityblock距离(曼哈顿距离)函数来计算网格中智能体间的距离
map_size = 30
env = magent.GridWorld("battle", map_size=map_size) # 使用30 x 30网格并设置“battle”模式
env.set_render_dir("MAgent/build/render") # 设置训练后我们能够观看博弈
team1, team2 = env.get_handles() # 初始化两个团队的对象
MAgent是高度可定制的,但是我们将使用被称为“battle”的内置设置来启动两队对抗的战斗场景。MAgent有一个类似于OpenAI gym的API,但也有一些重要的区别。首先,我们必须为两队各自设置“handles”。代码中get_handles返回的两个参数,即team 1和team 2,它们都是对象,具有与每个团队相关的方法和属性。我们通常将这些handles传递给环境对象env的方法。例如,为了获得team 1中每个智能体的坐标列表,我们使用env.get_pos(team1)。
我们将使用相同的技术来解决这个环境,正如我们使用二维伊辛模型一样,只不过是用两个DQN而已。我们将使用一个softmax策略和经验回放池。当智能体的数量随着训练过程而发生变化,如此事情会变得有点复杂,因为智能体会死亡并从网格中移除。
在伊辛模型中,环境的状态(state)是联合行动;没有额外的状态信息。但在MAgent中,我们就可以把智能体的位置和生命值作为状态信息。Q函数的形式是 Q j ( s t , a − j ) Q_j(s_t,a_{-j}) Qj(st,a−j),其中 a − j a_{-j} a−j是智能体 j 的视野范围(field of view,FOV)内或近邻的智能体们的的平均场。默认情况下,每个智能体会有一个围绕自身的13×13网格的FOV。因此,每个智能体将有一个13×13 FOV网格的状态(二进制表示),状态里出现1代表有其他智能体存在。然而,MAgent按团队分离出FOV矩阵,因此每个智能体都有两个13×13 FOV网格:一个用于自己的团队,一个用于另一个团队。我们需要通过将两个FOV矩阵展平并连接在一起,将它们组合成一个单一的状态向量。MAgent还提供了FOV中智能体们的生命值,但为了简单起见,我们不会使用它们。
我们已经初始化了环境,但我们还没有初始化网格上的智能体们。我们现在需要设置每个团队有多少个智能体以及把它们放在哪里。
# 清单 9.14 添加智能体们
hid_layer = 25
in_size = 359
act_space = 21
layers = [(in_size,hid_layer),(hid_layer,act_space)]
params = gen_params(2,in_size*hid_layer+hid_layer*act_space) # 为两个DQN初始化两组参数向量
map_size = 30
width = height = map_size
n1 = n2 = 16 # 设置每个团队的智能体数量为16
gap = 1 # 设置每个团队中的智能体之间的间距为1
epochs = 100
replay_size = 70
batch_size = 25
side1 = int(math.sqrt(n1)) * 2 # 开方主要是为了尽量弄个方形,*2是因为中间有个gap=1
pos1 = []
for x in range(width//2 - gap - side1, width//2 - gap - side1 + side1, 2): # 循环安置网格中左边team1的所有智能体位置
for y in range((height - side1)//2, (height - side1)//2 + side1, 2):
pos1.append([x, y, 0])
side2 = int(math.sqrt(n2)) * 2
pos2 = []
for x in range(width//2 + gap, width//2 + gap + side2, 2): # 循环安置网格中右边team2的所有智能体位置
for y in range((height - side2)//2, (height - side2)//2 + side2, 2):
pos2.append([x, y, 0])
env.reset()
env.add_agents(team1, method="custom", pos=pos1) # 使用刚才收集到的team1位置列表pos1来添加智能体
env.add_agents(team2, method="custom", pos=pos2)
这里我们已经设置了基本参数。我们创建了一个30×30网格,每个团队有16个智能体,以保持低计算成本,但如果你有一个GPU,可以用更大的网格装载更多的智能体们。我们初始化两个参数向量,各代表一个团队。同样,我们只使用了一个简单的两层神经网络作为DQN。我们现在可以可视化网格了。
>>> plt.imshow(env.get_global_minimap(30,30)[:,:,:].sum(axis=2))
team2在左边,team1在右边(图9.24)。所有的智能体们都被初始化为一个正方形模样,并且团队只被一个方格分隔。每个智能体的动作空间是一个长度为21的向量,如图9.25所示。在清单9.15中,我们引入了一个函数来查找一个某个智能体的邻近智能体们。
图 9.24 MAgent环境中两组智能体们的初始位置。较量的方块表示每个单独的智能体。
图 9.25 此图描述了MAgent库中智能体的动作空间。每个智能体可以在13个不同的方向上移动,或者直接在它周围的8个方向上进行攻击。转弯动作默认是禁用的,所以操作空间为13 + 8 = 21。
# 清单9.15 寻找近邻智能体们
def get_neighbors(j,pos_list,r=6): # 在pos_list中已经给出了所有智能体的位置形如[x,y],然后返回在智能体j所在位置的半径r范围内的所有智能体的索引。
neighbors = []
pos_j = pos_list[j]
for i,pos in enumerate(pos_list):
if i == j:
continue
dist = cityblock(pos,pos_j)
if dist < r:
neighbors.append(i)
return neighbors
我们需要这个get_neighbors函数来找到每个智能体的FOV中的近邻智能体们,以便能够计算出平均动作向量。我们可以使用env.get_pos(team1)来得到team1上每个智能体的坐标列表,然后我们可以将这个坐标列表(pos_list)与某个智能体索引( j ) 一起传递给get_neighbors函数,目的是找到智能体 j 的近邻智能体们。
>>> get_neighbors(5,env.get_pos(team1))
[0, 1, 2, 4, 6, 7, 8, 9, 10, 13]
上述代码举例了索引为5的智能体在其13×13 FOV中能够感受(感应)到团队team1中其他智能体们的影响有10个。
我们现在需要创建一些其他的辅助函数。环境接受的动作输入和返回都是整数0到20,因此我们需要将其转换为一个one-hot动作向量,并返回到整数形式。除此之外,我们还需要一个函数来求某个智能体的近邻智能体们的平均场向量。
def get_onehot(a,l=21): # 把整数形式表示的动作 转换至 one-hot表示形式
# l里面可选择13个方向移动或者8个方向攻击,总共21个选择,返回one-hot动作向量
x = torch.zeros(21)
x[a] = l
return x
def get_scalar(v):
# 转换one-hot动作向量至整数形式
return torch.argmax(v)
def get_mean_field(j,pos_list,act_list,r=7,l=21):
# 获取智能体j的平均场动作;pos_list通常用env.get_pos(team1)表示;l表示动作向量维度
neighbors = get_neighbors(j,pos_list,r=r) # 使用pos_list来找出智能体j的所有近邻智能体们
mean_field = torch.zeros(l) # 初始化one-hot变量形式,用来表示平均场
for k in neighbors:
act_ = act_list[k] # act_是整数形式
act = get_onehot(act_) # 转换至one-hot形式
mean_field += act # 叠加到mean_field中
tot = mean_field.sum() # 为归一化做准备,其实加起来就是近邻智能体总数
# 加入判断是为了确保我们不是除以0
mean_field = mean_field / tot if tot > 0 else mean_field
return mean_field
get_mean_field函数首先调用get_neighbors函数,来得到智能体 j 的所有近邻智能体们的坐标。然后,get_mean_field函数使用这些坐标来获取智能体的动作向量,并将它们相加,然后除以它们这些智能体个数的总和来进行归一化。get_mean_field函数还要求输入对应的动作向量 act_list(一个基于整数的动作列表),其中pos_list 和act_list中的索引都是对应同一个智能体的。参数 r 是以智能体 j 为中心 能够感受到网格中附近所有智能体的最远半径,l是动作空间的大小,默认为21。
与伊辛模型示例不同,我们要创建单独的函数来为每个智能体选择动作并进行训练,因为这是一个更复杂的环境,我们希望更模块化一点。在环境中的每一步之后,我们得到一个观察张量( observation tensor),这个观察张量对于所有智能体是一样的。
env.get_observation(team1)返回的观察结果实际上是一个元组,其包含着两个张量。第一个张量如图9.26的顶部所示。它是一个复杂的高阶张量,然而元组中的第二个张量有一些我们会忽略的额外信息。从现在开始,当我们说观测(observation)或状态(state)时,我们指的是第一个张量,如图9.26所示的。
图9.26显示了该观测张量以切片的形式排列。观察结果是一个N×13×13×7的张量,其中N是智能体的数量(在我们的例子中是16)。一个智能体的张量中包含了13×13切片们,这些切片分别显示了FOV与wall的位置(第0片)、team1的智能体们(第1片)、team1智能体们的HPs(第2片),等等。
图9.26 observation张量的结构。它是一个N x 13 x 13 x 7张量,其中N是团队中的智能体的数量。
我们将只使用切片1和切片4来表示某智能体视野范围内团队1和团队2上的智能体们的位置。所以,单个智能体的observation张量将是13×13×2,我们将把它展平成一个长度为338的状态向量。然后我们将这个状态向量与长度为21的平均场向量连接起来,得到一个长度为338 + 21 = 359的向量,然后把它传给Q函数。像我们在第七章中那样使用双头神经网络是很理想的。因为一个头就可以处理状态矢量,另一个头就可以处理平均场动作矢量,然后我们就可以在后面的一层中重新组合处理后的信息。我们在这里这样做不是为了简化,但这是一个很好的练习。在清单9.27中,我们定义了一个函数来让智能体选择动作,输入是它的观察结果(其近邻智能体们的平均场)。
# 清单 9.17 选择动作
def infer_acts(obs,param,layers,pos_list,acts,act_space=21,num_iter=5,temp=0.5):
N = acts.shape[0] # 获取智能体个数
mean_fields = torch.zeros(N,act_space)
acts_ = acts.clone() # 复制动作向量以避免改得不对
qvals = torch.zeros(N,act_space)
for i in range(num_iter): # 迭代多几次,让动作收敛
for j in range(N): # 迭代所有智能体,并计算它们的近邻智能体们的平均场动作向量
mean_fields[j] = get_mean_field(j,pos_list,acts_)
for j in range(N): # 使用状态和平均场向量去计算Q值,并且使用softmax函数来选择动作
state = torch.cat((obs[j].flatten(),mean_fields[j]))
qs = qfunc(state.detach(),param,layers=layers)
qvals[j,:] = qs[:]
acts_[j] = softmax_policy(qs.detach(),temp=temp)
return acts_, mean_fields, qvals
def init_mean_field(N,act_space=21):
mean_fields = torch.abs(torch.rand(N,act_space))# 随机初始化平均场向量
for i in range(mean_fields.shape[0]):
mean_fields[i] = mean_fields[i] / mean_fields[i].sum()
return mean_fields
这是一个在获取到观测值之后我们用来为每个智能体选择动作的函数。这个函数用到了被param和layers参数化了的平均场Q函数,然后又用到softmax策略为每个智能体对动作进行采样。infer_act函数的输入需要以下参数(每个参数背后的括号是向量维度):
- obs是观察 (observation) 张量,N×13×13×2。
- mean_fields是包含每个智能体的所有平均场动作的张量,N×21。
- pos_list是由env.get_pos(……)返回的每个智能体的位置列表。
- acts是每个智能体用整数所表示的动作向量(N,)。
- num_iter是指在动作采样和策略更新之间进行迭代的次数。
- temp是softmax策略的温度参数,以控制探索率。
该函数返回一个元组:
- acts_是从策略采样的整数表示的动作向量,(N,)。
- mean_fields_是每个智能体的平均场向量的张量,(N,21)。
- qvals是每个智能体每个动作的Q值张量,(N,21)。
最后,我们需要进行训练的函数。我们把参数向量( NN )和经验回放池传递到这个函数,并让它做mini-batch随机梯度下降。
# 清单9.18 训练函数
def train(batch_size,replay,param,layers,J=64,gamma=0.5,lr=0.001):
# 创建一个长度为batch_size的随机索引,用来取经验回放池的子集
ids = np.random.randint(low=0,high=len(replay),size=batch_size)
exps = [replay[idx] for idx in ids] # 取经验回放池的子集到exps里去
losses = []
jobs = torch.stack([ex[0] for ex in exps]).detach() # 从mini-batch里收集所有状态,放在一个向量里
jacts = torch.stack([ex[1] for ex in exps]).detach() # 同样,收集动作
jrewards = torch.stack([ex[2] for ex in exps]).detach() # 同样,收集奖励
jmeans = torch.stack([ex[3] for ex in exps]).detach() # 同样,收集平均场动作
vs = torch.stack([ex[4] for ex in exps]).detach() # 同样,收集状态价值
qs = []
for h in range(batch_size): # 迭代mini-batch里每个经验条目
state = torch.cat((jobs[h].flatten(),jmeans[h])) # 合并状态和平均场动作
qs.append(qfunc(state.detach(),param,layers=layers)) # 计算出每条经验的Q值
qvals = torch.stack(qs)
target = qvals.clone().detach()
target[:,jacts] = jrewards + gamma * torch.max(vs,dim=1)[0]
loss = torch.sum(torch.pow(qvals - target.detach(),2))
losses.append(loss.detach().item())
loss.backward()
with torch.no_grad():
param = param - lr * param.grad
param.requires_grad = True
return np.array(losses).mean()
这个函数的流程跟我们在清单9.12中使用二维伊辛模型时所做的经验回放相当相似,但是状态信息更为复杂。
train函数使用在经验回放记忆缓冲区中存储的经验来训练单个神经网络。它具有以下输入和输出:
- 输入:
- batch_size(int)
- replay,装着列表的元组(obs_1_small, acts_1,rewards1,act_means1,qnext1)
- param (vector) 神经网络参数向量
- layers (list) 包含神经网络各个层的形状
- J(int)这个团队的智能体的数量
- gamma(float,范围[0,1])折扣因子
- lr(float)SGD的学习率
- 返回:
- loss(float)
我们现在已经设置了环境,也为两个团队安置了智能体们,并定义了几个函数来让我们训练两个DQNs,两个DQNs是用于平均场Q-learning。现在我们准备看看游戏的主循环。值得注意的是,以下是在接下来的几个列表中有很多代码,但大部分只是例行公事,对理解整个算法并不重要。
让我们首先设置我们初步的数据结构,比如经验回放池。我们将需要分别为team1和team2提供单独的经验回放池。事实上,我们对team1和team2所做的事几乎一切都分开。
# 清单 9.19 初始化动作
N1 = env.get_num(team1) # 获取每个团队中的智能体的个数
N2 = env.get_num(team2)
step_ct = 0
acts_1 = torch.randint(low=0,high=act_space,size=(N1,)) # 初始化所有智能体们的动作
acts_2 = torch.randint(low=0,high=act_space,size=(N2,))
replay1 = deque(maxlen=replay_size) # 使用队列来创建经验回放池
replay2 = deque(maxlen=replay_size)
qnext1 = torch.zeros(N1) # 创建张量来存储Q(s')值,s'是下一个状态
qnext2 = torch.zeros(N2)
act_means1 = init_mean_field(N1,act_space) # 初始化每个智能体的平均场
act_means2 = init_mean_field(N2,act_space)
rewards1 = torch.zeros(N1) # 创建张量来存储每个智能体的奖励
rewards2 = torch.zeros(N2)
losses1 = []
losses2 = []
清单9.19中的变量们允许我们跟踪每个智能体的动作(整数表示)、平均场动作向量、奖励和下一个状态Q值,这样我们就可以将这些打包到经验条目中,并将它们添加到经验回放池中。在清单9.20中,我们定义了一个函数来代表某个团队的智能体们来执行行动,以及定义了另一个函数来在经验回放池中存储经验。
# 清单9.20 执行团队step,和添加经验回放
def team_step(team,param,acts,layers):
obs = env.get_observation(team) # 获取来自team1的观察张量(16x13x13x7)
ids = env.get_agent_id(team) # 获取还没死的智能体们的索引列表
obs_small = torch.from_numpy(obs[0][:,:,:,[1,4]]) # 取观察张量的子集,只取智能体们的位置
agent_pos = env.get_pos(team) # 获取团队中的所有智能体们的坐标列表
# 为每个智能体使用DQN来决定采取哪个动作
acts, act_means, qvals = infer_acts(obs_small,param,layers,agent_pos,acts)
return acts, act_means, qvals, obs_small, ids
# 把每个智能体的经验条目分别地添加到经验回放池
def add_to_replay(replay,obs_small, acts,rewards,act_means,qnext):
for j in range(rewards.shape[0]):
exp = (obs_small[j], acts[j],rewards[j],act_means[j],qnext[j])
replay.append(exp)
return replay
team_step函数是主循环的工作场所。我们使用它来收集环境中的所有数据,并运行DQN来决定采取哪些操作。add_to_replay函数将观察张量、动作张量、奖励张量、动作平均场张量和下一个状态Q值张量,并将每个智能体体验分别添加到重放缓冲区中。
代码的其余部分都在一个巨大的while循环中,所以我们将把它分成几个部分,但是要记住它都是同一个循环的一部分。还要记住,所有这些代码都放在这本书的GitHub页面的http://mng.bz/JzKp jupyter notebooks中。它包含了我们用于创建可视化的所有代码,以及更多的注释。最后在清单9.21中得到了该算法的主要训练循环。
# 清单 9.21 训练循环
for i in range(epochs):
done = False
while not done: # 游戏还没结束
acts_1, act_means1, qvals1, obs_small_1, ids_1 = team_step(team1,params[0],acts_1,layers) # 使用team_step方法收集环境数据,并使用DQN为智能体选择动作
env.set_action(team1, acts_1.detach().numpy().astype(np.int32)) # 在环境中实例化已选择的动作
acts_2, act_means2, qvals2, obs_small_2, ids_2 = team_step(team2,params[0],acts_2,layers)
env.set_action(team2, acts_2.detach().numpy().astype(np.int32))
done = env.step() # 启动环境执行下一步,将会返回新观察值和奖励
_, _, qnext1, _, ids_1 = team_step(team1,params[0],acts_1,layers) # 重新运行team_step,以获取环境中下一个状态的Q值
_, _, qnext2, _, ids_2 = team_step(team2,params[0],acts_2,layers)
env.render() # 渲染环境,以便稍后查看
rewards1 = torch.from_numpy(env.get_reward(team1)).float()
rewards2 = torch.from_numpy(env.get_reward(team2)).float()
while循环运行就意味着游戏还没结束;当一个team中的所有智能体都死亡时,游戏就结束了。在team_step函数中,我们首先得到观测张量和我们想要的子集,如我们前面描述的,得到一个13×13×2张量。我们还获取到了ids_1,这是team1中还没死的智能体们的索引。我们还需要得到每个团队中每个智能体的坐标位置。然后我们使用我们的infer_acts函数来为每个智能体选择动作并在环境中实例化这些动作,最后执行环境的step方法,这将产生新的观察和奖励。让我们继续这个循环。
# 清单9.22 添加到经验回放(这块代码仍然在清单9.21 的while循环中)
# 添加到经验回放
replay1 = add_to_replay(replay1, obs_small_1,acts_1,rewards1,act_means1,qnext1)
replay2 = add_to_replay(replay2, obs_small_2,acts_2,rewards2,act_means2,qnext2)
# 打乱回放池
shuffle(replay1)
shuffle(replay2)
# 建立一个zipped的IDs列表,用来跟踪哪些智能体死亡,然后把其从网格中清除
ids_1_ = list(zip(np.arange(ids_1.shape[0]),ids_1))
ids_2_ = list(zip(np.arange(ids_2.shape[0]),ids_2))
env.clear_dead() # 清除网格中已经死亡的智能体
ids_1 = env.get_agent_id(team1) # 既然死亡的智能体被清除了,重新获取新的智能体IDs
ids_2 = env.get_agent_id(team2)
ids_1_ = [i for (i,j) in ids_1_ if j in ids_1] # 取IDs旧列表子集,基于哪些智能体还没死
ids_2_ = [i for (i,j) in ids_2_ if j in ids_2]
acts_1 = acts_1[ids_1_] # 基于还没死的智能体,取其动作列表子集
acts_2 = acts_2[ids_2_]
step_ct += 1
if step_ct > 250:
break
# 如果经验回放池充足了,就可以开始训练了
if len(replay1) > batch_size and len(replay2) > batch_size:
loss1 = train(batch_size,replay1,params[0],layers=layers,J=N1)
loss2 = train(batch_size,replay2,params[1],layers=layers,J=N1)
losses1.append(loss1)
losses2.append(loss2)
在代码的最后一部分中,我们所做的就是将所有数据收集到一个元组中,并将其添加到经验回放池中以进行训练。MAgent的一个复杂性是,随着时间的推移,智能体的数量会随着时间的推移而减少,因此我们需要对我们的数组进行一些管理,以确保随着时间的推移,保持了数据与对应的智能体相匹配。
如果你只运行训练循环的几个epoch,智能体们将开始展示一些战斗技能,因为我们让网格非常小,每个队只有16个智能体。你可以通过这里的说明观看游戏的视频: http://mng.bz/aRdz。你应该能看到智能体们互相攻击,一些智能体会在视频结束之前被杀。图9.27是我们视频最后的截图,显示了其中一队在角落里攻击另一队,明显地击败了另一队。
图9.27 使用平均场Q-learning训练后的智能体战斗游戏截图。蓝队把红队逼到了角落上,并且正在攻击红队。
总结
- 普通Q-learning在多智能体环境中不怎么奏效,因为当其他智能体学习到新策略时,环境就会变得非平稳。
- 非平稳环境意味着奖励的期望值随时间而变化。
- 为了处理这种非平稳性,Q函数需要得到其他智能体的联合动作空间,但这个联合动作空间的规模随着智能体的数量呈指数增长,这对于大多数实际问题来说是很棘手的。
- 近邻Q-learning可以通过只计算某智能体的近邻智能体们的联合动作空间来减轻指数规模,但如果近邻智能体们的数量很大,还是依然会很棘手。
- 平均场Q-learning(MF-Q)与智能体的数量成线性关系,因为我们只计算一个平均动作,而不是一个完整的联合动作空间。