欢迎光临散文网 会员登陆 & 注册

《AI for Game Developers》14 神经网络篇机翻

2023-02-07 15:06 作者:此处有龍  | 我要投稿

感谢 @神经小番茄 

分享的 关于游戏中人工智能的视频,并且在视频下方提供下载资料链接。

经过几天的学习,我已经通过机翻翻译了其中神经网络部分文章,并记录下面,相关图片与更多篇章,还需要回到神经小番茄视频中下载

神经网络


我们的大脑由数十亿个神经元组成,每个神经元都与数千个其他神经元相连,形成一个具有非凡处理能力的复杂网络。人工神经网络,以下简称神经网络或网络,试图模仿我们大脑的处理能力,尽管规模要小得多。可以说,信息通过轴突和树突从一个神经元传递到另一个神经元。轴突携带电压电位或动作电位,从激活的神经元到其他连接的神经元。动作电位是从树突中的受体获取的。突触间隙是发生化学反应的地方,可以激发或抑制输入给定神经元的动作电位。图 14-1 说明了一个神经元

图 14-1
Figure 14-1. Neuron


成人大脑包含大约 10^11 个神经元,每个神经元接收来自大约 10^4 个其他神经元的突触输入。如果所有这些输入的综合效果足够强,神经元就会放电,将其动作电位传递给其他神经元。相比之下,我们在游戏中使用的人工网络非常简单。对于许多应用,人工神经网络仅由少数几个、十几个左右的神经元组成。这比我们的大脑要简单得多。一些特定的应用程序使用可能由数千个神经元组成的网络,但与我们的大脑相比,即使这些神经元也很简单。这个时候我们不能指望用我们的人工网络来接近人脑的处理能力;然而,对于特定问题,我们的简单网络可能非常强大。


这是神经网络的生物学隐喻。有时从不太生物学的角度来思考神经网络会很有帮助。具体来说,您可以将神经网络视为数学【函数逼近器】。网络的输入代表自变量,而输出代表因变量。网络本身是一个函数,为给定的输入提供一组独特的输出。这种情况下的函数很难写成方程式,幸运的是我们不需要这样做。此外,该函数是高度非线性的。我们稍后会回到这种思维方式.


对于游戏,神经网络与更传统的 AI 技术相比具有一些关键优势。首先,使用神经网络使游戏开发人员能够通过将关键决策过程委托给一个或多个训练有素的神经网络来简化复杂状态机或基于规则的系统的编码。其次,神经网络为游戏的人工智能提供了在玩游戏时进行调整的潜力。这是一个相当引人注目的可能性,并且在撰写本文时是游戏 AI 社区中非常流行的主题.


尽管有这些优势,神经网络还没有在视频游戏中得到广泛使用。游戏开发者已经在一些流行的游戏中使用了神经网络;但总的来说,它们在游戏中的使用是有限的。这可能是由于几个因素造成的,接下来我们将描述其中两个关键因素.


首先,神经网络擅长处理高度非线性的问题;使用传统方法无法轻松解决的问题。这有时会使准确理解网络在做什么以及它如何得出结果变得难以理解,这可能会让未来的测试人员感到不安。其次,有时很难预测神经网络将生成什么作为输出,尤其是当网络被编程为在游戏中学习或适应时。例如,与测试和调试有限状态机相比,这两个因素使得测试和调试神经网络相对困难。


此外,一些在游戏中使用神经网络的早期尝试试图解决完整的AI系统,即组装大量神经网络来处理给定游戏生物或角色可能遇到的最一般的 AI 任务。神经网络充当了整个人工智能系统,可以说是整个大脑。我们不提倡这种方法,因为它加剧了与可预测性、测试和调试相关的问题。相反,就像我们自己的大脑有许多专门处理特定任务的区域一样,我们建议您使用神经网络来处理特定的游戏 AI 任务,作为也使用传统 AI 技术的集成 AI 系统的一部分。通过这种方式,大多数 AI系统将是相对可预测的,而困难的AI任务或您想要利用学习和适应的任务将使用针对该任务严格训练的特定神经网络。



AI 社区使用许多不同种类的神经网络来解决各种问题,从财务到工程问题以及介于两者之间的许多问题。 神经网络通常与其他技术,例如模糊系统、遗传算法和概率方法,仅举几例。这个主题太大了,无法在一章中讨论,所以我们将把注意力集中在一个特别有用的一类神经网络。 我们将把注意力集中在一种神经网络上网络称为多层前馈网络。 这种类型的网络非常通用,并且是能够处理各种各样的问题。 在深入了解此类网络的细节之前,让我们

首先一般性地探讨如何在游戏中应用神经网络。




14.0


14.2.1 Control 控制器


神经网络通常用作机器人应用的神经控制器。在这些情况下,机器人的感觉系统向神经控制器提供相关输入,神经控制器的输出(可以由一个或多个输出节点组成)向机器人的电机控制系统发送适当的响应。例如,机器人坦克的神经控制器可能需要三个输入,每个输入指示是否在机器人前方或两侧感知到障碍物。 (也可以输入到每个感测到的障碍物的范围。)神经控制器可以有两个输出来控制其左右轨道的运动方向。一个输出节点可以设置左轨道向前或向后移动,而另一个可以设置右轨道向前或向后移动。结果输出的组合使机器人向前移动、向后移动、向左转或向右转。神经网络可能看起来像图中所示的那样 

"Figure 14-2"


游戏中也会出现非常相似的情况。事实上,您可以在游戏中使用计算机控制的半履带机械化单元。或者你可能想使用神经网络来处理宇宙飞船或飞机的飞行控制。在每一种情况下,您都会有一个或多个输入神经元和一个或多个输出神经元,它们将控制装置的推力、轮子、轨道或您正在模拟的任何运动方式


14.2.2 Threat Assessment (威胁评估)


技术并训练部队抵御或攻击计算机控制的基地。假设您决定使用神经网络为计算机控制的军队提供某种方法来预测玩家在游戏过程中的任何给定时间提出的威胁类型。一种可能的神经网络如图 14-3 所示。

14-3

该网络的输入包括敌方(玩家)地面单位的数量、敌方空中单位的数量、地面单位是否在移动的指示、空中单位是否在移动的指示、到地面单位的范围,以及到空中单位的范围。输出由指示四种可能威胁之一的神经元组成,包括空中威胁、地面威胁、空中和地面威胁,或没有威胁。在玩游戏期间给定适当的数据和评估网络性能的方法(我们稍后将讨论训练),您可以使用这样的网络来预测即将发生的攻击类型(如果有的话)。一旦评估了威胁,计算机就可以采取适当的行动。这可能包括部署地面或空中部队、加强防御、让步兵处于高度戒备状态,或者在没有威胁的情况下照常进行。


这种方法需要在游戏中训练和验证网络,但可能会根据玩家的游戏风格进行自我调整。此外,如果您为此任务使用基于规则或有限状态机类型的体系结构,则可以减轻找出所有可能的场景和阈值的任务。


14.2.3 Attack or Flee 攻击或逃跑


最后一个例子,假设你有一个持久的角色扮演游戏,你决定使用神经网络来控制游戏中某些生物的行为方式。现在假设您要使用神经网络来处理生物的决策过程,即生物是攻击、躲避还是徘徊,这取决于敌人(玩家)是否在该生物附近。图 14-4 显示了这样一个神经网络的外观。请注意,您只会使用此网络来决定是攻击、躲避还是徘徊。您可以使用其他游戏逻辑,例如我们之前讨论的追逐和躲避技术,来执行所需的动作。

14-4

在本例中我们有四个输入:

做出决定(这表明该生物是成群结队还是单独旅行); 

衡量生物生命值或生命值的指标; 

关于敌人是否参与的指示

与另一个生物战斗; 

最后,到敌人的范围。


我们可以通过添加更多输入(例如敌人的类别、敌人是法师还是战士等)使这个示例更复杂一些。这样的考虑对于其攻击策略和防御更适合于一种或另一种类的生物来说很重要。你可以通过“作弊”来确定敌人的类别,或者更好的是,你可以通过使用另一个神经网络或贝叶斯分析来预测敌人的类别,为整个过程增加一点不确定性。


14.1 Dissecting Neural Networks 剖析神经网络


在本节中,我们将剖析一个三层前馈神经网络,查看其每个组件以了解它们的作用、它们为何重要以及它们如何工作。这里的目的是清晰简洁地揭开神经网络的神秘面纱。我们将采取一种相当实用的方法来完成这项任务,并将一些更具学术性的方面留给有关该主题的其他书籍。我们将在本章中引用几本这样的书



14.2.4 Structure 结构


本章重点介绍三层前馈网络。图 14-5 说明了这种网络的基本结构

14-5

三层网络由一层输入层、一层隐藏层和一层输出层组成。每层内的神经元数量没有限制。输入层的每个神经元都连接到隐藏层中的每个神经元。此外,隐藏层中的每个神经元都连接到输出层中的每个神经元。此外,除了输入层之外,每个神经元都有一个额外的输入,称为偏差。【图14-5】中显示的数字用于标识三层中的每个节点。稍后我们将在编写计算每个神经元值的公式时使用此编号系统。


计算网络的输出值从提供给每个输入神经元的一些输入开始。然后这些输入被加权并传递给隐藏层神经元。 这个过程重复,从隐藏层到输出层,隐藏层神经元的输出作为输入到输出层。这个从输入层到隐藏层再到输出层的过程就是前馈过程。 我们将在以下部分更详细地研究此类网络的每个组件


14.2.5 Input 输入


神经网络的输入显然非常重要;没有它们,神经网络就无法处理任何东西。显然我们需要它们,但是您应该选择什么作为输入?你需要多少?他们应该采取什么形式?


14.2.1 Input: What and How Many? 


选择什么作为输入的问题是非常特定于问题的。你必须审视你试图解决的问题,并选择哪些游戏参数、数据和环境特征对手头的任务很重要。例如,假设您正在设计一个神经网络来对角色扮演游戏中的玩家角色进行分类,以便计算机控制的生物可以决定是否要与玩家互动。您可能会考虑的一些输入包括玩家着装的一些指示、他拔出的武器(如果有)以及可能的任何目击动作,例如,他是否刚刚施了咒语。


如果您将输入神经元的数量保持在最低限度,您训练神经网络的工作(我们将在后面讨论)将会更容易。但是,在某些情况下,要选择的输入对您而言并不总是显而易见的。在这种情况下,一般规则是包括您认为可能重要的输入,并让神经网络自行挑选出哪些是重要的。神经网络擅长理清输入对所需输出的相对重要性。但是请记住,您投入的输入越多,您为训练网络准备的数据就越多,您在游戏中必须进行的计算也就越多。


通常,您可以通过将重要信息组合或转换为其他更紧凑的形式来减少输入的数量。举个简单的例子,假设您正在尝试使用神经网络来控制飞船在您的游戏中降落在行星上。航天器的质量(可能是可变的)和地球重力加速度显然是重要因素,您应该将其作为输入提供给神经网络。事实上,您可以为每个参数创建一个输入神经元,一个用于质量,另一个用于重力加速度。然而,这种方法迫使神经网络在计算航天器质量与重力加速度之间的关系时执行额外的工作。捕获这两个重要参数的更好输入是单个神经元,它将航天器的重量(其质量乘以重力加速度的乘积)作为单个神经元的输入。当然,除了这个之外,还有其他输入神经元,例如,您可能还会有高度和速度输入.


14.2.2 Input: What Form?


您可以使用各种形式的数据作为神经网络的输入。 在游戏中,这种输入一般分为三种类型:布尔型、枚举型和连续型。 神经网络使用实数,因此无论您拥有何种类型的数据,都必须将其转换为合适的实数才能用作输入。


考虑图14-4中所示的示例。“敌人交战”输入显然是一个布尔类型,如果敌人交战则为真,否则为假。但是,我们不能将 true 或 false 传递给神经网络输入节点。相反,我们输入 1.0 表示真,0.0 表示假。

14-4

有时您的输入数据可能会被枚举。例如,假设您有一个旨在对敌人进行分类的网络,其中一个考虑因素是他所使用的武器类型。选择可能是诸如匕首、私生剑、长剑、武士刀、弩、短弓或长弓之类的东西。顺序在这里无关紧要,我们假设这些可能性是相互排斥的。通常,您使用所谓的 one-of-n 编码方法在神经网络中处理此类数据。基本上,您为每种可能性创建一个输入,并将输入值设置为 1.0 或 0.0,以对应每种特定可能性是否为真。例如,如果敌人挥舞着武士刀,则输入向量将为 {0, 0, 0, 1, 0, 0, 0},其中为武士刀输入节点设置 1,为所有其他节点设置 0可能性。



实际上,您的数据通常是浮点数或整数。在任何一种情况下,这种类型的数据通常可以采用一些实际上限和下限之间的任意数量的值。您只需将这些值直接输入神经网络(游戏开发人员经常这样做)。


但是,这可能会导致一些问题。如果您的输入值在数量级上变化很大,神经网络可能会为较大数量级的输入赋予更多权重。例如,如果一个输入的范围是 0 到 20,而另一个输入的范围是 0 到 20,000,则后者可能会抵消前者的影响。因此,在这些情况下,重要的是将此类输入数据缩放到在数量级方面具有可比性的范围。通常,您可以根据从 0 到 100 的百分比值或从 0 到 1 的值来缩放此类数据。以这种方式缩放可以为各种输入提供公平的竞争环境。但是,您必须小心缩放比例。您需要确保用于训练网络的数据的缩放方式与网络在现场看到的数据的缩放方式完全相同。例如,如果您按训练数据的屏幕宽度缩放距离输入值,则当网络在您的游戏中运行时,您必须使用相同的屏幕宽度来缩放输入数据。


14.2.6 Weights


神经网络中的权重类似于生物神经网络中的突触连接。权重会影响给定输入的强度,并且可以是抑制性的或兴奋性的。真正定义神经网络行为的是权重。此外,确定这些权重值的任务是训练或进化神经网络的主题.


从一个神经元到另一个神经元的每个连接都有一个相关的权重。如图14-5所示。神经元的输入是连接该神经元的每个输入权重的乘积之和乘以它的输入值加上偏置项,我们将在后面讨论。最终结果称为神经元的净输入。以下等式显示了如何从一组输入 i 神经元计算给定神经元神经元 j 的净输入.

公式

参考图 14-5,您可以看到神经元的每个输入都乘以这两个神经元之间的连接权重加上偏差。让我们看一个简单的例子(稍后我们将看一下这些计算的源代码).


假设我们要计算图 14-5 所示隐藏层中第 0 个神经元的净输入。应用前面的等式,我们得到隐藏层中第 0 个神经元的净输入的以下公式:

14-5

在这个公式中,ns 代表神经元的值。对于输入神经元,这些是输入值。在隐藏神经元的情况下,它们是净输入值。上标 h 和 i 表示神经元属于哪一层隐藏层和 i 表示输入层。下标表示每一层内的节点.


请注意,给定神经元的净输入只是其他神经元加权输入的线性组合。如果是这样,神经网络如何逼近我们之前提到的高度非线性函数?关键在于净输入如何转换为神经元的输出值。具体来说,激活函数以非线性方式将净输入映射到相应的输出


14.2.7 Activation Functions

激活函数获取神经元的净输入并对其进行操作以产生神经元的输出。激活函数应该是非线性的(除了一种情况,我们将在稍后讨论)。如果不是,则神经网络将简化为线性函数的线性组合,并且无法逼近非线性函数和关系.


最常用的激活函数是 logistic 函数或 sigmoid 函数。图 14-6 说明了这个 S 形函数。

逻辑函数的公式如下:

有时这个函数写成 thes 形式:

在这种情况下,c 项用于改变函数的形状,即沿水平轴拉伸或压缩函数.


请注意,输入位于水平轴上,对于 x 的所有值,此函数的输出范围从 0 到 1。实际上,工作范围更像是 0.1 到 0.9,其中 0.1 左右的值表示神经元未激活,而 0.9 左右的值表示神经元已激活。重要的是要注意,无论 x 变得多大(正或负),逻辑函数实际上永远不会达到 1.0 或 0.0;它渐近于这些值。训练时必须牢记这一点。如果您尝试训练您的网络,使其为给定的输出神经元输出值 1,您将永远无法达到目标。一个更合理的值是 0.9,达到这个值将极大地加快训练速度。如果您尝试训练网络输出值 0,这同样适用。请改用诸如 0.1 之类的值.


您也可以使用其他激活函数。图 14-7 和 14-8 显示了另外两个众所周知的激活函数:阶跃函数和双曲正切函数。

14-7 Step activation function
14-8 Hyperbolic activation tangent function

阶跃函数的公式如下:



阶跃函数用于早期的神经网络开发,但它们缺乏导数使得训练变得困难。请注意,逻辑函数有一个易于评估的导数,这是训练网络所必需的,我们很快就会看到。

双曲正切函数的公式如下:

有时会使用双曲正切函数,据说可以加快训练速度。其他激活函数在神经网络中用于各种应用;但是,我们不会在这里讨论这些。一般来说,逻辑函数似乎是使用最广泛的,适用于各种各样的应用.


Figure 14-9 显示了有时使用的另一个激活函数——线性激活函数。

线性激活函数的公式很简单:

这意味着神经元的输出只是净输入,即所有连接的输入神经元的加权输入加上偏置项的总和.


线性激活函数有时用作输出神经元的激活函数。请注意,如果不将网络简化为线性函数的线性组合,则必须对隐藏神经元使用非线性激活函数。当您不希望输出限制在 0 和 1 之间的区间时,使用这样的线性输出神经元有时很有用。在这种情况下,您仍然可以使用逻辑输出激活函数,只要您将输出缩放到最大您感兴趣的值范围.



14.2.8 Bias


当我们之前讨论如何计算神经元的净输入时,我们提到每个神经元都有一个与之相关的偏差。这表示为每个神经元的偏差值和偏差权重,并显示在我们之前显示的净输入公式中,为方便起见再次显示:

b j 是偏置值,wj 是偏置权重。


要了解偏差的作用,您必须查看用于在给定净输入的情况下为神经元生成输出的激活函数。基本上,偏置项沿激活函数的水平轴移动净输入,这有效地改变了神经元激活的阈值。偏差值始终设置为1或-1,其权重通过训练进行调整,就像所有其他权重一样。这实质上允许神经网络为每个神经元的激活学习适当的阈值.


一些从业者总是将偏差值设置为 1,而其他人则总是使用 -1。根据我们的经验,使用 1 还是 -1 并不重要,因为通过训练,网络会调整其偏差权重以适应您的选择。权重可以是正的也可以是负的,所以如果神经网络认为偏差应该是负的,它会调整权重来实现这一点,不管你选择 1 还是 - 1。如果你选择 1,它会找到一个合适的负值weight,而如果你选择-1,它会找到一个合适的正权重。当然,您可以通过训练或改进网络来实现所有这些,我们将在本章后面讨论。


14.2.9 Output


就像输入一样,您为给定网络选择的输出神经元是特定于问题的。一般来说,最好将输出神经元的数量保持在最低限度,以减少计算和训练时间.


考虑一个网络,其中给定特定输入,您希望输出对该输入进行分类。也许您想确定一组给定的输入是否属于某个类别。在这种情况下,您将使用一个输出神经元。如果它被激活,结果为真,而如果它没有被激活,结果为假——输入不属于所考虑的类别。如果您使用逻辑函数作为输出激活,则大约 0.9 的输出将表示已激活或为真,而大约 0.1 的输出将表示未激活或为假。在实践中,您实际上可能无法准确获得 0.9 或 0.1 的输出值;例如,您可能会得到 0.78 或 0.31。因此,您必须定义一个阈值,使您能够评估给定的输出值是否表示激活。通常,您只需在两个极端之间选择一个输出阈值。对于逻辑函数,您可以使用 0.5。如果输出大于0.5,则结果为激活或为真,否则为假.


当您对某个输入是否属于多个类别感兴趣时,您必须使用多个输出神经元。 考虑图 14-3 中所示的网络。 在这里,基本上我们想要对敌人造成的威胁进行分类; 这些类别是空中威胁、地面威胁、空中和地面威胁,或没有威胁。 每个类别都有一个输出神经元。 对于这种类型的输出,我们假设高输出值意味着激活,而低输出值意味着未激活。每个节点的实际输出值可以涵盖一定范围的值,具体取决于网络的训练方式和使用的输出激活函数的类型。给定一组输入值和每个输出节点的结果值,确定哪个输出被激活的一种方法是采用具有最高输出值的神经元。 这就是所谓的赢者通吃的方法。 具有最高激活的神经元表示生成的类。 我们将在本章后面看到这种方法的示例。


在其他情况下,如图 14-2 所示,您可能有多个输出神经元用于直接控制其他系统。在图 14-2 所示的示例中,输出值控制半履带机器人每条履带的运动。在该示例中,对输出神经元使用双曲正切函数可能很有用,这样输出值的范围将介于 -1 和 +1 之间。然后,负值可以表示向后运动,而正值可以表示向前运动。


有时您可能需要一个输出神经元与输入神经元一样多的网络。这种网络通常用于自动关联(模式识别)和数据压缩。此处的目标是输出神经元应响应输入值。对于模式识别,将训练这样的网络以输出其输入。训练集将包含许多感兴趣的样本模式。这里的想法是,当呈现出某种程度退化或与训练集中包含的模式不完全匹配的模式时,网络应该产生代表其训练集中包含的模式的输出,该模式与正在训练的模式最接近输入.


14.2.10 The Hidden Layer 隐藏层


到目前为止,我们已经讨论了输入神经元、输出神经元以及如何计算任何神经元的净输入,但我们还没有专门讨论隐藏层。在我们的三层前馈网络中,我们有一个隐藏的神经元层夹在输入层和输出层之间。

14-5

如图 14-5 所示,每个输入都连接到每个隐藏的神经元。此外,每个隐藏神经元将其输出发送到每个输出神经元。顺便说一句,这不是您可以使用的唯一神经网络结构;有各种各样的,有些有不止一个隐藏层,有些有反馈,有些根本没有隐藏层,等等。但是,它是最常用的配置之一。无论如何,隐藏层对于赋予网络设施以处理输入数据中的特征至关重要。隐藏的神经元越多,网络可以处理的特征就越多;相反,隐藏神经元越少,网络可以处理的特征就越少.


那么,我们所说的功能是什么意思?要理解我们在这里的意思,将神经网络视为函数逼近器会很有帮助。假设你有一个看起来非常嘈杂的函数,如图 14-10 所示。

14-10 Noisy function

如果你要训练一个神经网络来使用太少的隐藏神经元来逼近这样的函数,你可能会得到如图 14-11 所示的结果.

14-11. Approximation of noisy function using too few neurons

在这里,您可以看到近似函数捕获了输入数据的趋势,但遗漏了局部噪声特征。在某些情况下,例如对于信号降噪应用,这正是您想要的;但是,对于其他问题,您可能不希望这样做。如果你走另一条路并选择了太多的隐藏神经元,那么除了函数的整体趋势之外,近似函数可能还会拾取局部噪声特征。在某些情况下,这可能就是您想要的;然而,在其他情况下,您最终可能会得到一个过度训练的网络,并且无法对给定的不属于训练集的新输入数据进行泛化


对于给定的应用程序究竟使用多少隐藏神经元很难确定。通常,您通过反复试验来解决它。但是,这里有一条您可能会觉得有用的经验法则。对于你对自动关联不感兴趣的三层网络,合适的隐藏神经元数量大约等于输入和输出神经元数量乘积的平方根。这只是一个近似值,但它是一个很好的起点。需要记住的是,尤其是对于 CPU 使用率至关重要的游戏,隐藏神经元的数量越多,计算网络输出所需的时间就越多。因此,尽量减少隐藏神经元的数量是有益的



14.2 Training 训练


到目前为止,我们已经多次提到训练神经网络,但实际上并未向您提供有关如何执行此操作的详细信息。我们将在本节中解决这个问题


训练的目的是找到连接所有神经元的权重值,以便输入数据生成所需的输出值。正如您所料,训练不仅仅是选择一些权重值。本质上,训练神经网络是一个优化过程,在这个过程中,您试图找到最佳权重,使网络能够产生正确的输出


训练可以分为两类:有监督的"训练"和"无监督"的训练。涵盖所有甚至一些流行的训练方法远远超过一章,因此我们将重点关注最常用的监督训练方法之一:反向传播


14.2.11 Back-Propagation Training 逆向传播训练


同样,“训练的目的”是找到连接所有神经元的权重值,以便输入数据生成所需的输出值。为此,您需要一个训练集,其中包含“输入数据”和与该输入对应的所需输出值。下一步是迭代地使用多种技术中的任何一种,为整个网络找到一组权重,使网络产生与训练集中每组数据的所需输出相匹配的输出。完成此操作后,您就可以让网络开始工作并向其呈现未包含在训练集中的新数据,以产生合理的输出


因为训练是一个优化过程,所以我们需要一些衡量指标来优化。在反向传播的情况下,我们使用误差度量并尝试最小化误差。给定一些输入和生成的输出,我们需要将生成的输出与已知的期望输出进行比较,并量化结果的匹配程度,即计算误差。有许多误差度量可供您使用,我们将在这里使用最常见的一种:均方误差,它只是计算输出与所需输出之间差异的平方的平均值


如果您学习过微积分,您可能会记得要最小化或最大化函数,您需要能够计算函数的导数。因为我们试图通过最小化误差度量来优化权重,所以我们需要在某处计算导数也就不足为奇了。具体来说,我们需要激活函数的导数,这就是逻辑函数如此好用的原因——我们可以很容易地通过分析确定它的导数


正如我们之前提到的,找到最佳权重是一个迭代过程,它是这样的:

1. 从一个由输入数据和相应的期望输出组成的训练集开始。

2. 将神经网络中的权重初始化为一些小的随机值。

3. 使用每组输入数据,馈入网络并计算输出。

4. 将计算出的输出与期望的输出进行比较并计算误差。

5. 调整权重减少误差,重复上述过程


您可以通过两种方式执行该过程。一种方法是计算误差度量,调整每组输入和所需输出数据的权重,然后继续处理下一组输入/输出数据。另一种方法是计算训练集中所有输入和期望输出数据的累积误差,然后调整权重,然后重复这个过程。每次迭代称为一个纪元。


步骤 1 到 3 相对简单,稍后我们将看到示例实现。不过,现在让我们更仔细地检查步骤 4 和 5


14.2.3 Computing Error 计算误差


要训练神经网络,您需要为其提供一组输入,这会生成一些输出。要将此计算出的输出与给定输入集的所需输出进行比较,您需要计算误差。这使您不仅可以确定计算出的输出是对还是错,还可以确定其对或错的程度。最常使用的误差是均方误差,它是期望输出和计算输出之间差值的平方的平均值:


在这个等式中,是训练集的均方误差。 nc 和 nd 分别是所有输出神经元的计算输出值和期望输出值,而 m 是每个 epoch 的输出神经元数。


目标是通过迭代调整连接网络中所有神经元的权重值,使这个误差值尽可能小。要知道需要调整多少权重,每次迭代都需要我们还计算与输出层和隐藏层中每个神经元相关的误差。我们计算输出神经元的误差如下:


这里,io 是第 i 个输出神经元的误差,n io 是第 i 个输出神经元的计算输出和期望输出之间的差异,f'(ncio) 是第 i 个输出神经元的激活函数的导数。早些时候我们告诉过您我们需要在某处计算导数,这就是计算的地方。这就是逻辑函数如此有用的原因;它的导数形式很简单,很容易解析计算。使用logistic函数的导数重写此等式可得出以下输出神经元误差等式:

在这个等式中,n dio 是第 i 个神经元的期望输出值,ncio 是第 i 个神经元的计算输出值。

对于隐藏层神经元,误差方程有些不同。在这种情况下,与每个隐藏神经元相关的误差如下:


请注意,每个隐藏层神经元的误差是与隐藏神经元连接的每个输出层神经元相关的误差乘以每个连接的权重的函数。这意味着要计算误差并随后调整权重,您需要从输出层向输入层反向工作。


另请注意,再次需要激活函数导数。假设逻辑激活函数产生以下结果:



最后,没有错误与输入层神经元相关,因为这些神经元值是给定的。


14.2.4 Adjusting Weights 调整权重


有了计算出的误差,您就可以继续为网络中的每个权重计算适当的调整。对每个权重的调整如下:


在这个等式中,是学习率,i 是与所考虑的神经元相关的误差,ni 是所考虑的神经元的值。新权重就是旧权重加上 w。


请记住,每个重量都会进行重量调整,并且每个重量的调整都会有所不同。当更新连接输出到隐藏层神经元的权重时,输出神经元的误差和值计算权重调整。在更新连接隐藏层到输入层神经元的权重时,使用隐藏层神经元的误差和值。


学习率是一个乘数,会影响每个权重的调整量。它通常设置为一些较小的值,例如 0.25或0.5。这是您必须调整的参数之一。如果设置得太高,可能会超过最佳权重;如果设置得太低,训练可能需要更长的时间。


14.2.5 Momentum 动量


许多反向传播从业者对我们刚刚讨论的权重调整进行了细微的修改。这种改进的技术称为增加动量。在向您展示如何增加动力之前,让我们先讨论一下为什么您可能想要增加动力。


在任何一般优化过程中,目标都是最小化或最大化某些函数。更具体地说,我们有兴趣在某些输入参数范围内找到给定函数的全局最小值或最大值。问题是许多函数表现出所谓的局部最小值或最大值。这些基本上是函数中的凹陷和凸起,如图 14-12 所示。

14-12. Local extrema


在此示例中,该函数在所示范围内具有全局最小值和最大值;但它也有几个局部最小值和最大值,其特征是较小的凸起和凹陷。


在我们的例子中,我们感兴趣的是最小化我们网络的错误。具体来说,我们感兴趣的是找到产生全局最小误差的最佳权重;然而,我们可能会遇到局部最小值而不是全局最小值。


当网络训练开始时,我们将权重初始化为一些小的随机值。那时我们不知道这些值与最佳权重有多接近;因此,我们可能已将网络初始化为接近局部最小值而不是全局最小值。在不涉及微积分的情况下,我们更新权重的技术称为梯度下降类型的技术,我们使用函数的导数试图转向最小值,在我们的例子中是最小误差值。问题是我们不知道我们是否达到了全局最小值或局部最小值,通常错误空间,正如神经网络所称的那样,充满了局部最小值。


这种问题在所有优化技术中都很常见,许多不同的方法都试图缓解它。动量技术是一种用于神经网络的技术。它并没有消除收敛于局部最小值的可能性,但它被认为有助于摆脱它们并走向全局最小值,这也是它得名的地方。基本上,我们在权重调整中添加了一个小的附加分数,它是前一次迭代权重调整的函数。这会稍微推动权重调整,以便在接近局部最小值时,该算法有望超越局部最小值并继续朝着全局最小值前进。


因此,使用动量,计算权重调整的新公式如下:


在这个等式中,w' 是前一次迭代的权重调整,是动量因子。 动量因子是您必须调整的另一个因素。 它通常设置为 0.0 和 1.0 之间的一些小分数。


14.3 Neural Network Source Code 神经网络源代码


最后是时候查看一些实现三层前馈神经网络的实际源代码了。以下部分介绍了实现此类网络的两个 C++ 类。在本章的后面,我们将查看这些类的示例实现。如果您希望在查看神经网络的内部细节之前先了解神经网络的使用方式,请随时跳至标题为“用大脑追逐和逃避”的部分。


我们需要在三层前馈神经网络中实现两个类。第一类代表一个通用层。您可以将它用于输入层、隐藏层和输出层。第二类表示由三层组成的整个神经网络。以下部分展示了每个类的完整源代码。


14.2.12 The Layer Class


NeuralNetworkLayer 类在多层前馈网络中实现了一个通用层。它负责处理层中包含的神经元。它执行的任务包括分配和释放内存以存储神经元值、错误和权重;初始化权重;计算神经元值;和调整权重。示例 14-1 显示了此类的标头。


14-1. NeuralNetworkLayer class


class NeuralNetworkLayer

{

public:

    int NumberOfNodes;

    int NumberOfChildNodes;

    int NumberOfParentNodes;

    double** Weights;

    double** WeightChanges;

    double* NeuronValues;

    double* DesiredValues;

    double* Errors;

    double* BiasWeights;

    double* BiasValues;

    double LearningRate;

    bool LinearOutput;

    bool UseMomentum;

    double MomentumFactor;

    NeuralNetworkLayer* ParentLayer;

    NeuralNetworkLayer* ChildLayer;

    NeuralNetworkLayer();

    void Initialize(int NumNodes, NeuralNetworkLayer* parent, NeuralNetworkLayer* child);

    void CleanUp(void);

    void RandomizeWeights(void);

    void CalculateErrors(void);

    void AdjustWeights(void);

    void CalculateNeuronValues(void);

};


层以父子关系相互连接。例如,输入层是隐藏层的父层,隐藏层是输出层的父层。另外,输出层是隐藏层的子层,隐藏层是输入层的子层。注意输入层没有父层,输出层也没有子层。


此类的成员主要由存储神经元权重、值、误差和偏置项的数组组成。此外,一些成员存储了一些管理层行为的设置。成员如下


NumberOfNodes

    该成员存储层类的给定实例中神经元或节点的数量。


NumberOfChildNodes

    该成员存储连接到层类的给定实例的子层中的神经元数量。


NumberOfParentNodes

    该成员存储父层中连接到层类的给定实例的神经元数量

Weights

    该成员是指向双精度值的指针。基本上,这表示连接父层和子层之间节点的二维权重值数组。

WeightChanges

    该成员也是一个指向双精度值的指针,它访问一个动态分配的二维数组。在这种情况下,存储在数组中的值是对权重值所做的调整。正如我们之前讨论的那样,我们需要这些来实现动力

NeuronValues

    该成员是指向双精度值的指针,它访问动态分配的数组,该数组存储层中神经元的计算值或激活

DesiredValues

    该成员是指向双精度值的指针,它访问动态分配的数组,该数组存储层中神经元的所需或目标值。我们将其用于输出数组,我们在给定计算输出和训练集中的目标输出的情况下计算误差

Errors

    该成员是一个指向双精度值的指针,它访问一个动态分配的数组,该数组存储与层中每个神经元相关的错误。

BiasWeights

    该成员是一个指向双精度值的指针,它访问一个动态分配的数组,该数组存储连接到层中每个神经元的偏置权重。

BiasValues

    该成员是一个指向双精度值的指针,它访问一个动态分配的数组,该数组存储连接到层中每个神经元的偏置值。 请注意,这个成员并不是真正需要的,因为我们通常将偏差值设置为 +1 或 -1 并保持不变

LearningRate

    该成员存储学习率,计算权重调整

LinearOutput

    该成员存储一个标志,指示是否对层中的神经元使用线性激活函数。仅当图层是输出图层时才使用它。如果此标志为假,则改用逻辑激活函数。默认值为 false

UseMomentum

    该成员存储一个标志,指示在调整权重时是否使用动量。默认值为 false

MomentumFactor

    正如我们之前讨论的,该成员存储动量因子。仅当 UseMomentum 标志为真时才使用它。


ParentLayer

    该成员存储指向NeuralNetworkLayer实例的指针,该实例表示连接到给定层实例的父层。对于输入层,此指针设置为 NULL


ChildLayer

    该成员存储指向NeuralNetworkLayer实例的指针,该实例表示连接到给定层实例的子层。对于输出层,此指针设置为 NULL。


Example 14-2. NeuralNetworkLayer constructor 构造函数


NeuralNetworkLayer::NeuralNetworkLayer()

{

    ParentLayer = NULL;

    ChildLayer = NULL;

    LinearOutput = false;

    UseMomentum = false;

    MomentumFactor = 0.9;

}


构造函数非常简单。它所做的只是初始化一些我们已经讨论过的设置。示例 14-3 中所示的 Initialize 方法稍微复杂一些。


Example 14-3. Initialize method 初始化方法


void NeuralNetworkLayer::Initialize(int NumNodes,

                                                                                NeuralNetworkLayer* parent,

                                                                                NeuralNetworkLayer* child)

{

    int i, j;

    // Allocate memory

    NeuronValues = (double*) malloc(sizeof(double) * NumberOfNodes);

    DesiredValues = (double*) malloc(sizeof(double) * NumberOfNodes);

    Errors = (double*) malloc(sizeof(double) * NumberOfNodes);

    if(parent != NULL)

    {

        ParentLayer = parent;

    }

    if(child != NULL)

    {

        ChildLayer = child;

        Weights = (double**) malloc(sizeof(double*) *

                                                                NumberOfNodes);

        WeightChanges = (double**) malloc(sizeof(double*) *

                                                                             NumberOfNodes);

        for(i = 0; i<NumberOfNodes; i++)

        {

            Weights[i] = (double*) malloc(sizeof(double) *

                                                                        NumberOfChildNodes);

            WeightChanges[i] = (double*) malloc(sizeof(double) *

                                                                                    NumberOfChildNodes);

        }

        BiasValues = (double*) malloc(sizeof(double) *                                                                                                                                       NumberOfChildNodes);

         BiasWeights = (double*) malloc(sizeof(double) *                                                                                                                                     NumberOfChildNodes);

        } else {

             Weights = NULL;

             BiasValues = NULL;

             BiasWeights = NULL;

             WeightChanges = NULL;

        }

        // Make sure everything contains 0s

        for(i=0; i<NumberOfNodes; i++)

        {

             NeuronValues[i] = 0;

             DesiredValues[i] = 0;

             Errors[i] = 0;

             if(ChildLayer != NULL)

                  for(j=0; j<NumberOfChildNodes; j++)

                  {

                       Weights[i][j] = 0;

                       WeightChanges[i][j] = 0;

                  }

    }

    // Initialize the bias values and weights

    if(ChildLayer != NULL)

        for(j=0; j<NumberOfChildNodes; j++)

        {

            BiasValues[j] = -1;

            BiasWeights[j] = 0;

        }

}


Initialize 方法负责为动态数组分配所有内存,用于存储层中神经元的权重、值、误差、偏差值和权重。它还处理初始化所有这些数组。


该方法采用三个参数:层中节点或神经元的数量;指向父层的指针;和指向子层的指针。如果该层为输入层,父层指针应传入NULL。如果该层是输出层,则应为子层指针传入 NULL。


进入该方法后,会为NeuronValues、DesiredValues和Errors数组分配内存。所有这些数组都是一维的,条目数由层中的节点数定义。


接下来,设置父子层指针。如果子层指针不为 NULL,则我们有一个输入层或一个隐藏层,并且必须为连接权重分配内存。因为Weights和WeightChanges是二维数组,我们需要分步分配内存。第一步涉及分配内存以保存指向双精度数组的指针。这里的条目数对应于层中的节点数。接下来,我们为每个条目分配另一块内存来存储实际的数组值。这些附加块的大小对应于子层中的节点数。输入层或隐藏层中的每个神经元都连接到相关子层中的每个神经元;因此,权重和权重调整数组的总大小等于该层中的神经元数乘以子层中的神经元数。


我们还继续为偏差值和权重数组分配内存。这些数组的大小等于连接的子层中神经元的数量。


分配完所有内存后,初始化数组。在大多数情况下,我们希望所有内容都包含0,但偏差值除外,我们将所有偏差值条目设置为 -1。请注意,您可以将这些全部设置为 +1,正如我们之前讨论的那样。


Example 14-4 显示了 CleanUp 方法,它负责释放在 Initialization 方法中分配的所有内存。



Example 14-4. CleanUp method 清理方法


void NeuralNetworkLayer::CleanUp(void)

{

    int i;

    free(NeuronValues);

    free(DesiredValues);

    free(Errors);

    if(Weights != NULL)

    {

        for(i = 0; i<NumberOfNodes; i++)

        {

            free(Weights[i]);

            free(WeightChanges[i]);

        }

        free(Weights);

        free(WeightChanges);

    }

    if(BiasValues != NULL) free(BiasValues);

    if(BiasWeights != NULL) free(BiasWeights);

}


这里的代码是不言自明的。它只是使用 free 释放所有动态分配的内存。


前面我们提到神经网络权重在训练开始之前被初始化为一些小的随机数。例 14-5 中所示的 RandomizeWeights 方法为我们处理了这个任务。


Example 14-5. RandomizeWeights method 方法


void NeuralNetworkLayer::RandomizeWeights(void)

{

    int i,j;

    int min = 0;

    int max = 200;

    int number;

    srand( (unsigned)time( NULL ) );

    for(i=0; i<NumberOfNodes; i++)

    {

    for(j=0; j<NumberOfChildNodes; j++)

    {

        number = (((abs(rand())%(max-min+1))+min));

        if(number>max)       

            number = max;

        if(number<min)

            number = min;

        Weights[i][j] = number / 100.0f - 1;

    }

}

for(j=0; j<NumberOfChildNodes; j++)

{

                number = (((abs(rand())%(max-min+1))+min));

                if(number>max)

                number = max;

                if(number<min)

                number = min;

                BiasWeights[j] = number / 100.0f - 1;

    }

}


此方法所做的只是简单地为 Weights 数组中的每个权重计算一个介于 -1 和 +1 之间的随机数。它对存储在 BiasWeights 数组中的偏置权重执行相同的操作。您应该只在训练开始时调用此方法。


下一个方法 CalculateNeuronValues,负责使用我们之前向您展示的神经元净输入公式和激活函数计算层中每个神经元的激活值或值。示例 14-6 显示了此方法。


Example 14-6. CalculateNeuronValues method 计算神经元值方法


void NeuralNetworkLayer::CalculateNeuronValues(void)

{

    int i,j;

    double x;

    if(ParentLayer != NULL)

    {

        for(j=0; j<NumberOfNodes; j++)

        {

            x = 0;

            for(i=0; i<NumberOfParentNodes; i++)

            {

            x += ParentLayer->NeuronValues[i] *

                ParentLayer->Weights[i][j];

            }

            x += ParentLayer->BiasValues[j] *

                    ParentLayer->BiasWeights[j];

             if((ChildLayer == NULL) && LinearOutput)

                NeuronValues[j] = x;

             else

                    NeuronValues[j] = 1.0f/(1+exp(-x));

            }

      }

}


在此方法中,所有权重都使用嵌套的 for 语句进行循环。 j 循环循环遍历层节点(子层),而 i 循环循环遍历父层节点。在这些嵌套循环中,计算净输入并将其存储在x变量中。该层中每个节点的净输入是来自父层(第 i 个循环)馈送到每个节点(第 j 个节点)的所有连接的加权和,加上第 j 个节点的加权偏差。


在计算出每个节点的净输入后,您可以通过应用激活函数来计算每个神经元的值。您对除输出层之外的所有层都使用逻辑激活函数,在这种情况下,您根据 LinearOutput 标志使用线性激活函数。


示例 14-7 中所示的 CalculateErrors 方法负责使用我们之前讨论的公式计算与每个神经元相关的误差。


Example 14-7. CalculateErrors method 方法


void NeuralNetworkLayer::CalculateErrors(void)

{

    int i, j;

    double sum;

    if(ChildLayer == NULL) // output layer

    {

        for(i=0; i<NumberOfNodes; i++)

    {

        Errors[i] = (DesiredValues[i] - NeuronValues[i]) * 

                            NeuronValues[i] * 

                            (1.0f -NeuronValues[i]);


      }

    } else if(ParentLayer == NULL) { // input layer for(i=0; i<NumberOfNodes; i++)

        {

            Errors[i] = 0.0f;

        }

    } else { // hidden layer

        for(i=0; i<NumberOfNodes; i++)

        {

            sum = 0;

            for(j=0; j<NumberOfChildNodes; j++)

            {

                 sum += ChildLayer->Errors[j] * Weights[i][j];

            }

            Errors[i] = sum * NeuronValues[i] *(1.0f - NeuronValues[i]);

        }

    }

}


如果该层没有子层(仅当该层是输出层时才会发生),则使用输出层误差的公式。如果该层没有父层(仅当该层是输入层时才会发生),则错误设置为 0。如果该层同时具有父层和子层,则它是隐藏层,隐藏层的公式应用错误。


示例 14-8 中所示的 AdjustWeights 方法负责计算对每个连接权重进行的调整。


Example 14-8. AdjustWeights method 


void NeuralNetworkLayer::AdjustWeights(void)

{

    int i, j;

    double dw;

    if(ChildLayer != NULL)

    {

        for(i=0; i<NumberOfNodes; i++)

        {

            for(j=0; j<NumberOfChildNodes; j++)

            {

                dw = LearningRate * ChildLayer->Errors[j] *

                            NeuronValues[i];

                if(UseMomentum)

                {

                    Weights[i][j] += dw + MomentumFactor *

                                                            WeightChanges[i][j];

                    WeightChanges[i][j] = dw;

                } else {

                        Weights[i][j] += dw;

                }

            }

        }

        for(j=0; j<NumberOfChildNodes; j++)

        {

            BiasWeights[j] += LearningRate *ChildLayer->Errors[j] *BiasValues[j];

        }

    }

}


仅当该层具有子层时,即该层是输入层或隐藏层时,才会调整权重。输出层没有子层,因此没有连接和相关权重需要调整。嵌套的for循环循环遍历层中的节点和子层中的节点。请记住,层中的每个神经元都连接到子层中的每个节点。在这些嵌套循环中,权重调整是使用前面显示的公式计算的。如果要应用动量,则动量因子乘以前一个 epoch 的权重变化也会被添加到权重变化中。然后将这个时期的权重变化存储在下一个时期的 WeightChanges 数组中。如果不使用动量,则在没有动量的情况下应用重量变化,并且不需要存储重量变化。


最后,以类似于连接权重的方式调整偏置权重。对于连接到子节点的每个偏置,调整等于学习率乘以子神经元误差乘以偏置值。


14.2.13 神经网络类


NeuralNetwork类封装了NeuralNetworkLayer类的三个实例,一个用于网络中的每一层:输入层、隐藏层和输出层。示例 14-9 显示了类头。


Example 14-9. NeuralNetwork class


class NeuralNetwork

{

public:

    NeuralNetworkLayer InputLayer;

    NeuralNetworkLayer HiddenLayer;

    NeuralNetworkLayer OutputLayer;

    void Initialize(int nNodesInput, int nNodesHidden,

    int nNodesOutput);

    void CleanUp();

    void SetInput(int i, double value);

    double GetOutput(int i);

    void SetDesiredOutput(int i, double value);

    void FeedForward(void);

    void BackPropagate(void);

    int GetMaxOutputID(void);

    double CalculateError(void);

    void SetLearningRate(double rate);

    void SetLinearOutput(bool useLinear);

    void SetMomentum(bool useMomentum, double factor);

    void DumpData(char* filename);

};


该类中只有三个成员对应于构成该类的层。但是,这个类包含 13 个方法,我们将在接下来进行介绍。


Example 14-10  显示初始化方法

 

Example 14-10. Initialize method 初始化方法


void NeuralNetwork::Initialize(int nNodesInput,

                                                                    int nNodesHidden,

                                                                    int nNodesOutput)

{

    InputLayer.NumberOfNodes = nNodesInput;

    InputLayer.NumberOfChildNodes = nNodesHidden;

    InputLayer.NumberOfParentNodes = 0;

    InputLayer.Initialize(nNodesInput, NULL, &HiddenLayer);

    InputLayer.RandomizeWeights();

    HiddenLayer.NumberOfNodes = nNodesHidden;

    HiddenLayer.NumberOfChildNodes = nNodesOutput;

    HiddenLayer.NumberOfParentNodes = nNodesInput;

    HiddenLayer.Initialize(nNodesHidden,&InputLayer,&OutputLayer);

    HiddenLayer.RandomizeWeights();

    OutputLayer.NumberOfNodes = nNodesOutput;

    OutputLayer.NumberOfChildNodes = 0;

    OutputLayer.NumberOfParentNodes = nNodesHidden;

    OutputLayer.Initialize(nNodesOutput, &HiddenLayer, NULL);

}


Initialize 采用三个参数,对应于构成网络的三层中每一层中包含的神经元数量。这些参数初始化对应于输入层、隐藏层和输出层的层类的实例。 Initialize 还处理在层之间建立正确的父子连接。此外,它继续并随机化连接权重。


示例 14-11 中所示的 CleanUp 方法只是为每个层实例调用 CleanUp 方法。


Example 14-11. CleanUp method 清理方法


void NeuralNetwork::CleanUp()

{

    InputLayer.CleanUp();

    HiddenLayer.CleanUp();

    OutputLayer.CleanUp();

}


SetInput 用于设置特定输入神经元的输入值。示例 14-12 显示了 SetInput 方法。


Example 14-12. SetInput method 设置输入法


void NeuralNetwork::SetInput(int i, double value)

{

    if((i>=0) && (i<InputLayer.NumberOfNodes))

    {

        InputLayer.NeuronValues[i] = value;

    }

}


SetInput 有两个参数,对应于将为其设置输入的神经元的索引和输入值本身。该信息然后用于设置特定输入。您可以在训练期间使用此方法设置训练集输入,并在网络的现场使用期间设置将计算输出的输入数据。


Example 14-13. GetOutput method


double NeuralNetwork::GetOutput(int i)

{

    if((i>=0) && (i<OutputLayer.NumberOfNodes))

    {

        return OutputLayer.NeuronValues[i];

    }

    return (double) INT_MAX; // to indicate an error

}


GetOutput 有一个参数,即我们需要输出值的输出神经元的索引。该方法返回指定输出神经元的值或激活。请注意,如果您指定的索引超出有效输出神经元的范围,则将返回 INT_MAX 以指示错误。


在训练期间,我们需要将计算的输出与期望的输出进行比较。图层类有助于计算以及存储所需的输出值。示例 14-14 中所示的 SetDesiredOutput 方法用于帮助将所需的输出设置为对应于一组给定输入的值。


Example 14-14. SetDesiredOutput method SetDesiredOutput 方法


void NeuralNetwork::SetDesiredOutput(int i, double value)

{

    if((i>=0) && (i<OutputLayer.NumberOfNodes))

    {

        OutputLayer.DesiredValues[i] = value;

    }

}


SetDesiredOutput 有两个参数,对应于设置期望输出的输出神经元的索引和期望输出本身的值。


为了让网络在给定一组输入的情况下实际生成输出,我们需要调用示例 14-15 中所示的 FeedForward 方法



Example 14-15. FeedForward method 前馈方法


void NeuralNetwork::FeedForward(void)

{

    InputLayer.CalculateNeuronValues();

    HiddenLayer.CalculateNeuronValues();

    OutputLayer.CalculateNeuronValues();

}


此方法只是依次为输入层、隐藏层和输出层调用CalculateNeuronValues方法。这些调用完成后,输出层将包含计算出的输出,然后可以通过调用 GetOutput 方法对其进行检查。


在训练期间,一旦计算出输出,我们就需要使用反向传播技术调整连接权重。 BackPropagate 方法处理此任务。示例 14-16 显示了 BackPropagate 方法。



14-16 显示了 BackPropagate 方法


void NeuralNetwork::BackPropagate(void)

{

    OutputLayer.CalculateErrors();

    HiddenLayer.CalculateErrors();

    HiddenLayer.AdjustWeights();

    InputLayer.AdjustWeights();

}


BackPropagate 首先按顺序为输出层和隐藏层调用CalculateErrors方法。然后它继续按顺序为隐藏层和输入层调用 AdjustWeights方法。顺序在这里很重要,它必须是示例14-16中显示的顺序,即我们通过网络向后工作而不是向前工作,如前馈情况.


当使用具有多个输出神经元的网络和赢者通吃的方法来确定哪个输出被激活时,您需要找出哪个输出神经元具有最高输出值。例 14-17 中所示的 GetMaxOutputID 就是为此目的而提供的.


Example 14-17. GetMaxOutputID method 获取最大输出ID 方法


int NeuralNetwork::GetMaxOutputID(void)

{

    int i, id;

    double maxval;

    maxval = OutputLayer.NeuronValues[0];

    id = 0;

    for(i=1; i<OutputLayer.NumberOfNodes; i++)

    {

        if(OutputLayer.NeuronValues[i] > maxval)

        {

            maxval = OutputLayer.NeuronValues[i];

            id = i;

        }

    }

return id;

}


GetMaxOutputID 简单地遍历所有输出层神经元以确定哪个具有最高输出值。返回具有最高值的神经元的索引。


之前我们讨论了计算与给定输出集相关的误差的需要。出于培训目的,我们需要这样做。CalculateError方法负责为我们计算误差。示例 14-18 显示了 CalculateError 方法。


Example 14-18. CalculateError method


double NeuralNetwork::CalculateError(void)

{

    int i;

    double error = 0;

    for(i=0; i<OutputLayer.NumberOfNodes; i++)

    {

        error += pow(OutputLayer.NeuronValues[i] --

        OutputLayer.DesiredValues[i], 2);

    }

    error = error / OutputLayer.NumberOfNodes;

    return error;

}


CalculateError 使用我们之前讨论的均方误差公式返回与计算输出值和给定的一组期望输出值相关联的误差值。


为方便起见,我们提供了 SetLearningRate方法,如示例14-19所示。您可以使用它来设置构成网络的每一层的学习率


Example 14-19. SetLearningRate method 设置学习率方法


void NeuralNetwork::SetLearningRate(double rate)

{

    InputLayer.LearningRate = rate;

    HiddenLayer.LearningRate = rate;

    OutputLayer.LearningRate = rate;

}


SetLinearOutput,如示例 14-20 所示,是另一种便捷方法。您可以使用它为网络中的每一层设置 LinearOutput 标志。但是请注意,在此实现中只有输出层会使用线性激活。




Example 14-20. SetLinearOutput method 设置线性输出方法


void NeuralNetwork::SetLinearOutput(bool useLinear)

{

    InputLayer.LinearOutput = useLinear;

    HiddenLayer.LinearOutput = useLinear;

    OutputLayer.LinearOutput = useLinear;

}


您使用 SetMomentum,如示例 14-21 所示,为网络中的每一层设置 UseMomentum 标志和动量因子。




Example 14-21. SetMomentum method 设置动量方法


void NeuralNetwork::SetMomentum(bool useMomentum, double factor)

{

    InputLayer.UseMomentum = useMomentum;

    HiddenLayer.UseMomentum = useMomentum;

    OutputLayer.UseMomentum = useMomentum;

    InputLayer.MomentumFactor = factor;

    HiddenLayer.MomentumFactor = factor;

    OutputLayer.MomentumFactor = factor;

}


DumpData 是一种方便的方法,它只是将网络的一些重要数据流式传输到输出文件。示例 14-22 显示了 DumpData 方法。


Example 14-22 DumpData method 转储数据方法


void NeuralNetwork::DumpData(char* filename)

{

    FILE* f;

    int i, j;

    f = fopen(filename, "w");

    fprintf(f, "---------------------------------------------\n");

    fprintf(f, "Input Layer\n");

    fprintf(f, "---------------------------------------------\n");

    fprintf(f, "\n");

    fprintf(f, "Node Values:\n");

    fprintf(f, "\n");

    for(i=0; i<InputLayer.NumberOfNodes; i++)

    fprintf(f, "(%d) = %f\n", i, InputLayer.NeuronValues[i]);

    fprintf(f, "\n");

    fprintf(f, "Weights:\n");

    fprintf(f, "\n");

    for(i=0; i<InputLayer.NumberOfNodes; i++)

    for(j=0; j<InputLayer.NumberOfChildNodes; j++)

    fprintf(f, "(%d, %d) = %f\n", i, j,

    InputLayer.Weights[i][j]);

    fprintf(f, "\n");

    fprintf(f, "Bias Weights:\n");

    fprintf(f, "\n");

    for(j=0; j<InputLayer.NumberOfChildNodes; j++)

    fprintf(f, "(%d) = %f\n", j, InputLayer.BiasWeights[j]);

    fprintf(f, "\n");

    fprintf(f, "\n");

    fprintf(f, "---------------------------------------------\n");

    fprintf(f, "Hidden Layer\n");

    fprintf(f, "---------------------------------------------\n");

    fprintf(f, "\n");

    fprintf(f, "Weights:\n");

    fprintf(f, "\n");

    for(i=0; i<HiddenLayer.NumberOfNodes; i++)

    for(j=0; j<HiddenLayer.NumberOfChildNodes; j++)

    fprintf(f, "(%d, %d) = %f\n", i, j,

    HiddenLayer.Weights[i][j]);

    fprintf(f, "\n");

    fprintf(f, "Bias Weights:\n");

    fprintf(f, "\n");

    for(j=0; j<HiddenLayer.NumberOfChildNodes; j++)

    fprintf(f, "(%d) = %f\n", j, HiddenLayer.BiasWeights[j]);

    fprintf(f, "\n");

    fprintf(f, "\n");

    fprintf(f, "---------------------------------------------\n");

    fprintf(f, "Output Layer\n");

    fprintf(f, "---------------------------------------------\n");

    fprintf(f, "\n");

    fprintf(f, "Node Values:\n");

    fprintf(f, "\n");

    for(i=0; i<OutputLayer.NumberOfNodes; i++)

    fprintf(f, "(%d) = %f\n", i, OutputLayer.NeuronValues[i]);

    fprintf(f, "\n");

    fclose(f);

}


发送到给定输出文件的数据包括构成网络的层的权重、值和偏置权重。当您想要检查给定网络的内部结构时,这很有用。这在调试时以及在您可能使用实用程序训练网络并希望在实际游戏中硬编码训练的权重而不是花时间在游戏中执行初始训练的情况下很有用。对于后一个目的,您必须修改此处显示的 NeuralNetwork 类,以便于从外部源加载权重。



14.4 Chasing and Evading with Brains 动脑筋追逃


我们将在本节中讨论的示例是我们在第 4 章中讨论的集群和追逐示例的修改版。在那一章中,我们讨论了一个示例,其中一群单位追逐玩家控制的单位。在这个修改过的例子中,计算机控制的单元将使用神经网络来决定是追逐玩家、躲避他,还是与其他计算机控制的单元一起蜂拥而至。此示例是游戏场景的理想化或近似,您在游戏中拥有可以让玩家参与战斗的生物或单位。您不想让生物总是攻击玩家,也不想使用有限状态机“大脑”,而是希望使用神经网络不仅为生物做出决策,而且根据它们攻击玩家的经验来调整它们的行为。


下面是我们的简单示例的工作方式。大约 20 个计算机控制单元将在屏幕上移动。他们会攻击玩家,从玩家身边逃跑,或者与其他计算机控制的单位一起蜂拥而至。所有这些行为都将使用我们在前面章节中介绍的确定性算法来处理;然而,这里关于执行什么行为的决定取决于神经网络。玩家可以随心所欲地在屏幕上移动。当玩家和计算机控制的单位彼此进入指定半径范围内时,我们将假设他们正在进行战斗。我们不会在这里实际模拟战斗,而是使用一个基本系统,计算机控制的单位在玩家的战斗范围内时,在游戏循环中每回合都会失去一定数量的生命值。玩家将失去一定数量的生命值,与战斗范围内计算机控制的单位数量成正比。当一个单位的生命值达到零时,他会死亡并自动重生。


所有计算机控制的单元共享同一个大脑——神经网络。随着计算机控制的单元获得玩家的经验,我们还将让这个大脑进化。我们将通过在游戏本身中实施反向传播算法来实现这一点,以便我们可以实时调整网络的权重。我们假设计算机控制的单元共同进化。


我们希望看到计算机控制的单位学会在玩家在战斗中压倒他们时避开玩家。相反,我们希望看到计算机控制的单位在知道他们手上有弱者时变得更具侵略性。另一种可能性是计算机控制的单元将学会留在群体或群体,他们更有可能击败玩家。



14.2.14 Initialization and Training 初始化和训练


以第 4 章的植绒示例为起点,我们要做的第一件事是添加一个新的全局变量,称为 TheBrain,以表示神经网络,如示例 14-23 所示。



Example 14-23. New global variable 新的全局变量


NeuralNetwork TheBrain;


我们必须在程序开始时初始化神经网络。这里,初始化包括配置和训练神经网络。前面示例中的 Initialize 函数显然是处理神经网络初始化的地方,如示例 14-24 所示


Example 14-24. Initialization

void Initialize(void)

{

    int i;

    .

    .

    .

    for(i=0; i<_MAX_NUM_UNITS; i++)

    {

        .

        .

        .

        Units[i].HitPoints = _MAXHITPOINTS;

        Units[i].Chase = false;

        Units[i].Flock = false;

        Units[i].Evade = false;

    }

    .

    .

    .

    Units[0].HitPoints = _MAXHITPOINTS;

    TheBrain.Initialize(4, 3, 3);

    TheBrain.SetLearningRate(0.2);

    TheBrain.SetMomentum(true, 0.9);

    TrainTheBrain();

}


此版本 Initialize 中的大部分代码与前面的示例相同,因此我们在示例14-24的代码清单中省略了它。剩下的代码是我们添加的用于处理将神经网络合并到示例中的代码。


请注意,我们必须向刚体结构添加一些新成员,如示例14-25所示。这些新成员包括生命值的数量,以及指示该单位是在追逐、躲避还是蜂拥而至的标志。


Example 14-25. RigidBody2D class 


class RigidBody2D {

public:

.

.

.

    double HitPoints;

    int NumFriends;

    int Command;

    bool Chase;

    bool Flock;

    bool Evade;

    double Inputs[4];

};


另请注意,我们添加了一个输入向量。这用于在用于确定单元应该采取什么动作时将输入值存储到神经网络;


回到示例 14-24 中的 Initialize方法,在单元初始化之后是处理TheBrain的时候了。我们做的第一件事是调用神经网络的 Initialize 方法,将代表每一层神经元数量的值传递给它。在这种情况下,我们有四个输入神经元、三个隐藏神经元和三个输出神经元。该网络类似于图 14-4 中所示的网络。


接下来我们要做的是将学习率设置为0.2。我们通过反复试验调整了这个值,目的是在保持准确性的同时减少训练时间。接下来我们调用SetMomentum方法表示我们要在训练时使用动量,我们将动量因子设置为 0.9


现在网络已经初始化,我们可以通过调用函数 TrainTheBrain 来训练它。示例 14-26 显示了 TrainTheBrain 函数。


Example 14-26. TrainTheBrain function 训练大脑功能


void TrainTheBrain(void)

{

    int i;

    double error = 1;

    int c = 0;

    TheBrain.DumpData("PreTraining.txt");

    while((error > 0.05) && (c<50000))

    {

        error = 0;

        c++;

        for(i=0; i<14; i++)

        {

            TheBrain.SetInput(0, TrainingSet[i][0]);

            TheBrain.SetInput(1, TrainingSet[i][1]);

            TheBrain.SetInput(2, TrainingSet[i][2]);

            TheBrain.SetInput(3, TrainingSet[i][3]);

            TheBrain.SetDesiredOutput(0, TrainingSet[i][4]);

            TheBrain.SetDesiredOutput(1, TrainingSet[i][5]);

            TheBrain.SetDesiredOutput(2, TrainingSet[i][6]);

            TheBrain.FeedForward();

            error += TheBrain.CalculateError();

            TheBrain.BackPropagate();

        }

        error = error / 14.0f;

    }

    TheBrain.DumpData("PostTraining.txt");

}


在我们开始训练网络之前,我们将其数据转储到一个文本文件中,以便我们在调试期间可以参考它。接下来,我们进入一个使用反向传播算法训练网络的 while 循环。 while 循环循环直到计算出的误差小于某个指定值,或者直到迭代次数达到指定的最大阈值。后一种情况是为了防止 while 循环在永远不会达到错误阈值的情况下永远循环。


在仔细研究 while 循环中发生的事情之前,让我们先看一下用于训练该网络的训练数据。称为 TrainingSet 的全局数组用于存储训练数据。示例 14-27 显示了训练数据。


Example 14-27. Training data 训练数据


double TrainingSet[14][7] = {

//#Friends, Hit points, Enemy Engaged, Range, Chase, Flock, Evade

0, 1, 0, 0.2, 0.9, 0.1, 0.1,

0, 1, 1, 0.2, 0.9, 0.1, 0.1,

0, 1, 0, 0.8, 0.1, 0.1, 0.1,

0.1, 0.5, 0, 0.2, 0.9, 0.1, 0.1,

0, 0.25, 1, 0.5, 0.1, 0.9, 0.1,

0, 0.2, 1, 0.2, 0.1, 0.1, 0.9,

0.3, 0.2, 0, 0.2, 0.9, 0.1, 0.1,

0, 0.2, 0, 0.3, 0.1, 0.9, 0.1,

0, 1, 0, 0.2, 0.1, 0.9, 0.1,

0, 1, 1, 0.6, 0.1, 0.1, 0.1,

0, 1, 0, 0.8, 0.1, 0.9, 0.1,

0.1, 0.2, 0, 0.2, 0.1, 0.1, 0.9,

0, 0.25, 1, 0.5, 0.1, 0.1, 0.9,

0, 0.6, 0, 0.2, 0.1, 0.1, 0.9

};


训练数据由 14 组输入和输出值组成。每组包含四个输入节点的值,代表一个单位的朋友数量、它的生命值、敌人是否已经交战以及到敌人的距离。每个集合还包含三个输出节点的数据,对应于追逐、聚集和逃避行为。


请注意,所有数据值都在 0.0 到 1.0 的范围内。如前所述,所有输入数据都按比例缩放到 0.0 到 1.0 的范围内,并且由于使用了逻辑输出函数,每个输出值的范围都在0.0到1.0之间。我们稍后会看到输入数据是如何缩放的。至于输出,输出达到 0.0 或 1.0 是不切实际的,因此我们使用0.1表示非活动输出,0.9表示活动输出。另请注意,这些输出值表示相应输入数据集的所需输出。


我们根据经验选择训练数据。基本上,我们假设了一些任意输入条件,然后指定对该输入的合理响应是什么,并相应地设置输出值。在实践中,您可能会对此进行更多思考,并且可能会使用比我们在此处为这个简单示例所做的更多的训练集。


现在,让我们回到例 14-26中处理反向传播训练的while循环。进入while循环后,错误被初始化为 0。我们将计算每个时期的错误,它由所有 14 组输入和输出值组成。对于每组数据,我们设置输入神经元值和期望的输出神经元值,然后调用网络的FeedForward方法。之后,我们就可以计算误差了。为此,我们调用网络的 CalculateError 方法并将结果累积到错误变量中。然后我们通过调用 BackPropagate方法继续调整连接权重。在一个时期完成这些步骤后,我们通过将误差除以该时期中数据集的数量14来计算该时期的平均误差。在训练结束时,网络的数据被转储到一个文本文件中供以后检查。


此时,神经网络已准备就绪。您可以按原样将其与经过训练的连接权重一起使用。这将为您省去编写有限状态机等代码以处理所有可能的输入条件的麻烦。网络的一个更有吸引力的应用是允许它在运行中学习。如果根据网络做出的决定,这些单元表现良好,我们可以强化这种行为。另一方面,如果单元表现不佳,我们可以重新训练网络以抑制不良决策。



14.2.0 Learning 学习

在本节中,我们将继续查看实现神经网络的代码,包括使用反向传播算法在游戏中学习的能力。查看示例 14-28 中所示的 UpdateSimulation 函数。这是我们在第4章中讨论的UpdateSimulation 函数的修改版本。为清楚起见,示例 14-28 仅显示了对该函数的修改。


Example 14-28. Modified UpdateSimulation function 修改更新模拟功能



“此处有码”


我们在修改后的 UpdateSimulation 函数中做的第一件新事情是计算当前与目标交战的计算机控制单元的数量。在我们的简单示例中,如果一个单位与目标的距离在指定距离内,则该单位被认为正在与目标交战。


一旦我们确定了参与单位的数量,我们就会从目标中扣除与参与单位数量成比例的一定数量的生命值。如果目标生命值达到零,则认为目标已被杀死并在屏幕中间重生。此外,kill 标志设置为 true。


下一步是处理计算机控制的单元。对于这个任务,我们输入一个 for 循环来循环遍历所有计算机控制的单元。进入循环后,我们计算当前单元到目标的距离。接下来我们检查目标是否被杀死。如果是,我们会检查当前单位相对于目标的位置,即它是否在交战范围内。如果是,我们将重新训练神经网络以加强追逐行为。从本质上讲,如果该单位正在与目标交战并且目标死亡,我们会假设该单位正在做正确的事情并且我们会加强追逐行为以使其更具侵略性。



Example 14-29 显示处理网络再训练的函数.


Example 14-29. ReTrainTheBrain function 重新训练大脑功能


{

     int          i;

     Vector      u;

     bool         kill = false;


     // calc number of enemy units currently engaging the target

     Vector d;

     Units[0].NumFriends = 0;

     for(i=1; i<_MAX_NUM_UNITS; i++)

     {

          d = Units[i].vPosition -- Units[0].vPosition;

          if(d.Magnitude() <= (Units[0].fLength *

                               _CRITICAL_RADIUS_FACTOR))

                 Units[0].NumFriends++;

     }

     // deduct hit points from target

     if(Units[0].NumFriends > 0)

     {

          Units[0].HitPoints --= 0.2 * Units[0].NumFriends;

          if(Units[0].HitPoints < 0)

          {

               Units[0].vPosition.x = _WINWIDTH/2;

               Units[0].vPosition.y = _WINHEIGHT/2;

               Units[0].HitPoints = _MAXHITPOINTS;

               kill = true;

          }

     }

     // update computer-controlled units:

     for(i=1; i<_MAX_NUM_UNITS; i++)

     {

          u = Units[0].vPosition -- Units[i].vPosition;

          if(kill)

          {

               if((u.Magnitude() <= (Units[0].fLength *

                                     _CRITICAL_RADIUS_FACTOR)))

               {

                    ReTrainTheBrain(i, 0.9, 0.1, 0.1);

               }

          }

          // handle enemy hit points, and learning if required

          if(u.Magnitude() <= (Units[0].fLength *

                               _CRITICAL_RADIUS_FACTOR))

          {

               Units[i].HitPoints --= DamageRate;

               if((Units[i].HitPoints < 0))

               {

                 Units[i].vPosition.x=GetRandomNumber(_WINWIDTH/2

                                        --_SPAWN_AREA_R,

                                        _WINWIDTH/2+_SPAWN_AREA_R,

                                        false);

                  Units[i].vPosition.y=GetRandomNumber(_WINHEIGHT/2

                                        --_SPAWN_AREA_R,

                                        _WINHEIGHT/2+_SPAWN_AREA_R,

                                        false);

                  Units[i].HitPoints = _MAXHITPOINTS/2.0;

                  ReTrainTheBrain(i, 0.1, 0.1, 0.9);

               }

          } else {

               Units[i].HitPoints+=0.01;

               if(Units[i].HitPoints > _MAXHITPOINTS)

                   Units[i].HitPoints = _MAXHITPOINTS;

          }

          // get a new command

          Units[i].Inputs[0] = Units[i].NumFriends/_MAX_NUM_UNITS;

          Units[i].Inputs[1] = (double) (Units[i].HitPoints/

                                         _MAXHITPOINTS);

          Units[i].Inputs[2] = (Units[0].NumFriends>0 ? 1:0);

          Units[i].Inputs[3] = (u.Magnitude()/800.0f);

          TheBrain.SetInput(0, Units[i].Inputs[0]);

          TheBrain.SetInput(1, Units[i].Inputs[1]);

          TheBrain.SetInput(2, Units[i].Inputs[2]);

          TheBrain.SetInput(3, Units[i].Inputs[3]);

          TheBrain.FeedForward();

          Units[i].Command = TheBrain.GetMaxOutputID();

          switch(Units[i].Command)

          {

               case 0:

                    Units[i].Chase = true;

                    Units[i].Flock = false;

                    Units[i].Evade = false;

                    Units[i].Wander = false;

                    break;

               case 1:

                    Units[i].Chase = false;

                    Units[i].Flock = true;

                    Units[i].Evade = false;

                    Units[i].Wander = false;

                    break;

               case 2:

                    Units[i].Chase = false;

                    Units[i].Flock = false;

                    Units[i].Evade = true;

                    Units[i].Wander = false;

                    break;

          }

          DoUnitAI(i);

     } // end i-loop

     kill = false;

}

我们在修改后的 UpdateSimulation 函数中做的第一件新事情是计算当前与目标交战的计算机控制单元的数量。 在我们的简单示例中,如果一个单位与目标的距离在指定距离内,则该单位被认为正在与目标交战。

一旦我们确定了参与单位的数量,我们就会从目标中扣除与参与单位数量成比例的一定数量的生命值。 如果目标生命值达到零,则认为目标已被杀死并在屏幕中间重生。 此外,kill 标志设置为 true。

下一步是处理计算机控制的单元。 对于这个任务,我们输入一个 for 循环来循环遍历所有计算机控制的单元。 进入循环后,我们计算当前单元到目标的距离。 接下来我们检查目标是否被杀死。 如果是,我们会检查当前单位相对于目标的位置——也就是说,它是否在交战范围内。 如果是,我们将重新训练神经网络以加强追逐行为。 从本质上讲,如果该单位正在与目标交战并且目标死亡,我们会假设该单位正在做正确的事情并且我们会加强追逐行为以使其更具攻击性。

示例 14-29 显示了处理网络再训练的函数。

示例 14-29。 ReTrainTheBrain 函数

ReTrainTheBrain简单地再次实现反向传播训练算法,但这次将给定单元的存储输入和指定的目标输出用作训练数据。此处请注意,您不想将 while 循环的最大迭代阈值设置得太高。如果这样做,在进行再训练过程时,动作中可能会出现明显的停顿。此外,如果您尝试重新训练网络以实现非常小的错误,它会适应得太快。您可以通过改变错误和最大迭代阈值来控制网络适应的速率。


UpdateSimulation 函数的下一步是处理当前单位的生命值。如果当前单位在目标的交战范围内,我们会从该单位中扣除规定数量的生命值。如果该单位的生命值达到零,我们假设它在战斗中死亡,在这种情况下我们会在某个随机位置重生它。我们还假设这个单位做错了什么,所以我们重新训练这个单位逃避而不是追逐。


现在我们继续使用神经网络为单位做出决定——也就是说,在当前的一组条件下,单位应该追逐、聚集还是躲避。 为此,我们首先设置神经网络的输入。 第一个输入值是当前单位的好友数。 我们通过将单位常量的最大数量除以该单位的朋友数量来缩放朋友数量。 第二个输入是单位的生命值数,它是通过将最大生命值数除以单位的生命值数来换算的。 第三个输入是关于目标是否参与的指示。 如果目标已参与,则此值设置为 1.0,如果未参与,则设置为 0.0。 最后,第四个输入是到目标的距离。 在这种情况下,通过将屏幕宽度(假设为 800 像素)划分为范围来缩放当前单元到目标的距离。


设置所有输入后,将通过调用FeedForward方法传播网络。调用之后,可以检查网络的输出值以得出正确的行为。为此,我们选择具有最高激活的输出,这是通过调用 GetmaxOutputID 方法确定的。然后在 switch 语句中使用此 ID 为该单元设置适当的行为标志。如果 ID 为 0,则该单位应追逐。如果 ID 为 1,则该单位应聚集。如果 ID 为 2,则该单元应回避。


这会处理修改后的 UpdateSimulation 函数。如果你运行这个示例程序,它可以从本书的网站 (http://www.oreilly.com/catalog/ai) 获得,你会看到计算机控制单元的行为确实随着模拟的运行而调整。您可以使用数字键来控制目标对单位造成的伤害程度。 1 键对应很少或没有损坏,而 8 键对应大量损坏。如果你让目标在没有对单位造成伤害的情况下死亡,你会发现他们很快就会适应更频繁的攻击。如果你设置目标使其造成巨大伤害,你会看到单位开始适应以更多地避开目标。他们也开始更频繁地参与团体活动,而不是作为个人参与。最终,他们适应不惜一切代价避开目标。从这个例子中产生的一个有趣的涌现行为是,单位倾向于形成羊群,而领导者倾向于出现。通常会形成一个羊群,领先的单位可能会追逐或躲避,而中间和尾随的单位会跟在他们的前面。


14.5 Further Information 更多信息


正如我们在本章开头所说的那样,神经网络的主题过于庞大,无法用一章来解决。因此,我们编制了一份简短的参考资料清单,如果您决定进一步研究这个主题,您可能会发现它们很有用。名单如下:


Practical Neural Network Recipes in C++ (Academic Press)

Neural Networks for Pattern Recognition (Oxford University Press)

AI Application Programming (Charles River Media)


还有许多其他关于神经网络的书籍;然而,我们上面列出的那些对我们非常有用,尤其是第一个,C++中的实用神经网络食谱。本书针对各种应用程序的神经网络编程和使用提供了大量实用技巧和建议


《AI for Game Developers》14 神经网络篇机翻的评论 (共 条)

分享到微博请遵守国家法律