Python-神经演化实用指南-全-

Python 神经演化实用指南(全)

原文:zh.annas-archive.org/md5/c4cc7adb41ad9d14715dc730f23708c6

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

随着传统深度学习方法在能力上几乎达到极限,越来越多的研究人员开始寻找训练人工神经网络的替代方法。

深度机器学习在模式识别方面非常有效,但在需要理解上下文或以前未见过的数据任务中却失败了。包括深度机器学习现代形态之父杰夫·辛顿在内的许多研究人员都认为,目前设计人工智能系统的方法已经无法应对当前面临的挑战。

在这本书中,我们讨论了传统深度机器学习方法的可行替代方案——神经进化算法。神经进化是一系列使用进化算法来简化解决复杂任务(如游戏、机器人和自然过程模拟)的机器学习方法。神经进化算法受到自然选择过程的启发。非常简单的人工神经网络可以进化成非常复杂的网络。神经进化的最终结果是网络的优化拓扑结构,这使得模型更加节能且便于分析。

在这本书中,你将了解各种神经进化算法,并学会如何使用它们解决不同的计算机科学问题——从经典的强化学习到构建迷宫中自主导航的代理。此外,你还将学习如何使用神经进化来训练深度神经网络,创建能够玩经典Atari游戏的代理。

这本书旨在通过逐步指导,通过实施各种实验,让你对神经进化方法有一个扎实的理解。它涵盖了游戏、机器人技术和自然过程模拟等领域的实际例子,使用真实世界的例子和数据集帮助你更好地理解所探讨的概念。阅读完这本书后,你将拥有将神经进化方法应用于其他类似实验的任务所需的一切。

在撰写这本书时,我的目标是为你提供关于前沿技术的知识,这是传统深度学习的重要替代品。我希望你在项目中应用神经进化算法能够以优雅且节能的方式解决你目前难以解决的问题。

这本书面向的对象

本书面向机器学习从业者、深度学习研究人员和希望从头开始实现神经进化算法的AI爱好者。你将学习如何将这些算法应用于各种现实世界问题。你将了解神经进化方法如何优化人工神经网络训练过程。你将熟悉神经进化的核心概念,并获得在工作和实验中使用它的必要实践技能。掌握Python、深度学习和神经网络基础知识是必须的。

本书涵盖内容

第1章神经进化方法概述,介绍了遗传算法的核心概念,如遗传算子和基因组编码方案。

第2章Python库和环境设置,讨论了神经进化方法的实际方面。本章提供了流行Python库的优缺点,这些库提供了NEAT算法及其扩展的实现。

第3章使用NEAT进行XOR求解器优化,在这里你将通过实现一个经典计算机科学问题的求解器来开始对NEAT算法进行实验。

第4章极*衡实验,在这里你将继续进行与强化学习领域中计算机科学经典问题相关的实验。

第5章自主迷宫导航,在这里你将通过尝试创建一个能够从迷宫中找到出口的求解器,继续你的神经进化实验。你将学习如何实现一个具有传感器阵列的机器人模拟,以检测障碍物并监控其在迷宫中的位置。

第6章新颖性搜索优化方法,在这里你将利用在前一章创建迷宫求解器时获得的实际经验,开始创建更高级求解器的道路。

第7章基于超立方体的NEAT进行视觉辨别,介绍了高级神经进化方法。你将了解间接基因组编码方案,该方案使用组合模式产生网络CPPNs)来帮助编码大型表型人工神经网络拓扑结构。

第8章ES-HyperNEAT和视网膜问题,在这里你将学习如何选择最适合特定问题空间的底物配置。

第9章协同进化和SAFE方法,在这里我们讨论了协同进化策略在自然界中广泛存在,并且可以被转移到神经进化领域。

第10章深度神经进化,向你介绍了深度神经进化的概念,它可以用来训练深度人工神经网络DNNs)。

第11章最佳实践、技巧和窍门,教你如何开始处理手头的任何问题,如何调整神经进化算法的超参数,如何使用高级可视化工具,以及可以用于算法性能分析的哪些指标。

第12章结语,总结了你在本书中学到的所有内容,并为你的自学提供了进一步的方向。

为了充分利用本书

掌握Python编程语言的实用知识对于使用本书中的示例至关重要。为了更好地理解源代码,最好使用支持Python语法高亮和代码参考位置的IDE。如果你没有安装,可以使用Microsoft Visual Studio Code。它是免费且跨*台的,你可以从这里下载:https://code.visualstudio.com

本书讨论的Python及其大多数库都是跨*台的,兼容Windows、Linux和macOS。书中描述的所有实验都是从命令行执行的,因此请熟悉你选择的操作系统上安装的终端控制台应用程序。

要完成第10章中描述的实验,深度神经进化,你需要访问一台配备Nvidia显卡GeForce GTX 1080Ti或更好的现代PC。此实验在Ubuntu Linux环境中运行也更好。Ubuntu是一个免费且功能强大的基于Linux的现代操作系统。熟悉它将对你有很大帮助。

下载示例代码文件

你可以从www.packt.com的账户下载本书的示例代码文件。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。

你可以通过以下步骤下载代码文件:

  1. www.packt.com登录或注册。

  2. 选择“支持”选项卡。

  3. 点击“代码下载”。

  4. 在搜索框中输入书籍名称,并遵循屏幕上的说明。

下载文件后,请确保使用最新版本的软件解压缩或提取文件夹。

  • Windows的WinRAR/7-Zip

  • Mac的Zipeg/iZip/UnRarX

  • Linux的7-Zip/PeaZip

本书代码包也托管在GitHub上,网址为https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python。如果代码有更新,它将在现有的GitHub仓库中更新。

我们还有其他来自我们丰富图书和视频目录的代码包可供选择,请访问https://github.com/PacktPublishing/。查看它们吧!

下载彩色图片

我们还提供了一份包含本书中使用的截图/图表的彩色图片PDF文件。您可以从这里下载:https://static.packt-cdn.com/downloads/9781838824914_ColorImages.pdf

使用的约定

本书使用了多种文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟URL、用户输入和Twitter昵称。以下是一个示例:“您可以从Chapter10目录执行以下命令开始实验。”

代码块设置如下:

if indices is None:
            indices = np.arange(self.batch_size)

任何命令行输入或输出都如下所示:

$ conda create -n deep_ne python=3.5

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中如下所示。以下是一个示例:“从管理面板中选择系统信息。”

警告或重要注意事项如下所示。

技巧和窍门如下所示。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并给我们发送邮件至customercare@packtpub.com

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您能向我们报告。请访问www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上遇到我们作品的任何非法副本,我们将非常感激您能提供位置地址或网站名称。请通过链接材料与我们联系至copyright@packt.com

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

评论

请留下评论。一旦您阅读并使用过这本书,为何不在购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们Packt公司可以了解您对我们产品的看法,并且我们的作者可以查看他们对书籍的反馈。谢谢!

想了解更多关于Packt的信息,请访问packt.com

第一章:第1节:进化计算算法和神经进化方法的基本原理

本节介绍了进化计算的核心概念,并讨论了基于神经进化的算法的特定细节以及哪些 Python 库可以用来实现它们。您将熟悉神经进化方法的基本原理,并获得有关如何开始您实验的实用建议。本节作为环境设置的一部分,提供了对 Python 的 Anaconda 软件包管理器的初步介绍。

本节包含以下章节:

第二章:神经进化方法概述

人工神经网络ANN)的概念灵感来源于人脑的结构。人们坚信,如果我们能够以非常相似的方式模仿这种复杂的结构,我们就能创造出人工智能。我们仍在通往这一目标的道路上。尽管我们可以实现窄AI代理,但我们离创建通用AI代理还远着呢。

本章向您介绍了人工神经网络的概念以及我们可以用来训练它们的两种方法(带有误差反向传播的梯度下降和神经进化),以便它们学会如何逼*目标函数。然而,我们将主要关注讨论基于神经进化的算法系列。您将了解受自然进化启发的进化过程的实现,并熟悉最流行的神经进化算法:NEAT、HyperNEAT和ES-HyperNEAT。我们还将讨论我们可以用来搜索最终解决方案的优化方法,并在基于目标搜索和新颖性搜索算法之间进行比较。到本章结束时,您将对神经进化算法的内部结构有一个完整的理解,并准备好将此知识应用于实践。

在本章中,我们将涵盖以下主题:

  • 进化算法和基于神经进化的方法

  • NEAT算法概述

  • 基于超立方体的NEAT

  • 可进化基座HyperNEAT

  • 新颖性搜索优化方法

进化算法和基于神经进化的方法

人工神经网络这一术语代表由链接连接的节点图,其中每个链接都有一个特定的权重。神经网络节点定义了一种阈值运算符,它允许信号在应用特定的激活函数之后才通过。它远程地类似于大脑中神经元的组织方式。通常,ANN的训练过程包括选择网络中所有链接的适当权重值。因此,ANN可以逼*任何函数,可以被认为是通用逼*器,这是由通用逼*定理所确立的。

如需了解更多关于通用逼*定理证明的信息,请参阅以下论文:

在过去的70年里,提出了许多人工神经网络训练方法。然而,在本十年中获得声誉的最流行技术是由Jeffrey Hinton提出的。它基于通过网络反向传播预测误差,并在网络节点之间的连接权重上围绕损失函数相对于梯度下降的各种优化技术构建。它展示了训练深度神经网络在主要与模式识别相关的任务上的卓越性能。然而,尽管它具有内在的力量,但它有显著的缺点。其中一个缺点是需要大量的训练样本才能从特定的数据集中学习到有用的东西。另一个显著的缺点是实验者手动创建的固定网络架构,这导致计算资源的低效使用。这是由于大量的网络节点没有参与推理过程。此外,基于反向传播的方法在将获得的知识转移到其他类似领域时存在问题。

除了反向传播方法之外,还有一些非常有前途的进化算法可以解决上述问题。这些生物启发技术从达尔文的进化论中汲取灵感,并使用自然进化的抽象来创建人工神经网络。神经进化的基本思想是通过使用基于群体的随机搜索方法来产生人工神经网络。有可能通过进化过程进化出神经网络的优化架构,这些架构能够准确完成特定任务。因此,可以创建出紧凑且节能的网络,同时具有适中的计算能力需求。进化过程通过在许多代中对染色体群体(ANN/solutions的遗传编码表示)应用遗传算子(变异交叉)来执行。核心信念是,由于这是在生物系统中,后续的代将能够承受由目标函数表达出的代际压力,也就是说,它们将成为目标函数更好的*似器。

接下来,我们将讨论遗传算法的基本概念。你需要对遗传算法有一个中等水*以上的理解。

遗传算子

遗传算子是每个进化算法的核心,任何神经进化算法的性能都取决于它们。主要有两种遗传算子:变异和交叉(重组)。

在本章中,你将学习遗传算法的基本知识以及它们与使用基于误差反向传播方法的常规算法的不同之处。

变异算子

变异算子起着在进化过程中保持种群遗传多样性的基本作用,并在种群中生物体的染色体变得过于相似时防止局部最小值停滞。这种变异根据实验者定义的变异概率改变染色体中的一个或多个基因。通过向求解器的染色体引入随机变化,变异允许进化过程在可能解的搜索空间中探索新的区域,并在代际之间找到更好和更好的解。

下图显示了常见的变异算子类型:

图片

变异算子类型

具体的变异算子类型取决于特定遗传算法使用的遗传编码类型。在遇到的多种变异类型中,我们可以区分以下几种:

  • 位反转:随机选择的位被反转(二进制编码)。

  • 顺序变化:随机选择两个基因,并在基因组中翻转它们的位置(排列编码)。

  • 值变化:在表达基因的随机位置添加一个小的值(值编码)。

  • 基因表达变化:随机选择一个基因并从基因型(结构编码)中添加/移除。

基因型可以使用具有固定和可变染色体长度的遗传编码方案进行编码。前三种变异可以应用于两种类型的编码方案。最后一种变异只能表达在已使用可变长度编码编码的基因型中。

交叉算子

交叉(重组)算子允许我们通过重新组合两个父母的遗传信息以生成后代来随机生成新的一代(解)。因此,来自父母生物体的良好解的部分可以结合起来,并可能产生更好的后代。通常,在交叉之后,产生的后代在添加到下一代种群之前会被变异。

下图显示了各种交叉算子:

图片

交叉算子类型

不同的交叉算子类型也取决于特定算法使用的遗传编码,但以下是最常见的:

  • 单点交叉:随机选择交叉点,从开始到交叉点的基因组部分复制到来自一个亲本的子代,其余部分来自另一个亲本。

  • 两点交叉:两个交叉点随机选择,从第一个点到开始的部分基因组来自第一个亲本,第一个和第二个交叉点之间的部分来自第二个亲本,其余部分来自第一个亲本。

  • 均匀交叉:基因从第一个或第二个亲本随机复制。

基因组编码方案

设计神经进化算法时最重要的选择之一是确定神经网络的遗传表示,这可以通过以下方式进化

  • 标准突变(参见前面的 突变算子 子节)

  • 组合算子(参见前面的 交叉算子 子节)

目前,存在两种主要的基因组编码方案:直接和间接。让我们更详细地考虑每个方案。

直接基因组编码

在神经进化方法中,直接基因组编码尝试用于创建与具有固定拓扑的神经网络相关的 ANNs;也就是说,网络拓扑完全由实验者决定。在这里,遗传编码(基因型)实现为一个表示网络节点之间连接强度(权重)的实数向量。

进化算子通过突变算子修改权重向量的值,并通过重组(交叉)算子结合亲本有机体的向量以产生后代。虽然允许进化算子轻松应用,但所描述的编码方法有一些显著的缺点。其主要缺点之一是网络拓扑从一开始就由实验者决定,并在算法执行的所有代中固定。这种方法与自然进化过程相矛盾,在自然进化过程中,不仅有机体的属性,而且其物理结构在进化过程中也会发生变化。这使我们能够探索最广泛的搜索空间并找到最优解。

下面的图显示了进化过程:

图片

进化过程

为了解决固定拓扑方法的缺点,Kenneth O. Stanley 提出了 增强拓扑的神经进化NEAT)方法。该算法背后的主要思想是,进化算子不仅应用于所有连接权重的向量,还应用于创建的神经网络拓扑。因此,通过生成有机体的种群,测试了具有各种连接权重的各种拓扑。我们将在本章后面讨论 NEAT 算法的具体细节。

NEAT算法在各种任务中表现出卓越的性能——从传统的强化学习到控制计算机游戏中的复杂非玩家角色——并已成为最受欢迎的神经进化算法之一。然而,它属于直接编码算法的家族,这限制了其只能用于进化适度规模的ANN,其中参数空间限制在最多数千个连接。这是因为每个连接都直接编码在基因型中,随着编码连接数量的增加,计算需求显著增加。这使得无法使用该算法进化大型神经网络。

间接基因组编码

为了克服直接编码的大小问题,Kenneth O. Stanley提出了一个间接编码方法,该方法受到DNA中基因组如何编码表型的启发。它基于这样一个事实,即物理世界是围绕几何和规律性(结构模式)构建的,其中自然对称性无处不在。因此,任何物理过程的编码大小可以通过重复使用一组特定的编码块来显著减少,这些编码块用于重复多次的结构。提出的方法,称为基于超立方体的增强拓扑神经进化HyperNEAT),旨在通过利用几何规律性来构建大规模神经网络。HyperNEAT采用一个连接的组合模式生成网络CPPN)来表示节点连接作为笛卡尔空间中的函数。我们将在本章后面更详细地讨论HyperNEAT。

协同进化

在自然界中,不同物种的种群常常在相互作用的共同进化中同时进化。这种种间关系被称为协同进化。协同进化是自然进化的强大工具,它吸引了神经进化社区的注意也就不足为奇了。协同进化主要有三种类型:

  • 互利共生,即两种或更多物种共存并相互受益。

  • 竞争协同进化

    • 捕食,即一种生物杀死另一种生物并消耗其资源的行为。

    • 寄生,即一种生物利用另一种生物的资源,但并不杀死它。

  • 共生,即一种物种的成员在没有造成伤害或从其他物种中获得利益的情况下获得利益。

研究人员已经探讨了先前的协同进化策略,并揭示了它们的优缺点。在这本书中,我们将介绍一种采用共生原理的神经进化算法,以维持两个协同进化的种群:候选解决方案的种群和候选目标函数的种群。我们将在第9章协同进化和SAFE方法中讨论解决方案和适应度进化SAFE)算法。

模块化和层次结构

自然认知系统组织的另一个关键方面是模块化和层次结构。在研究人脑时,神经科学家发现它不是一个具有统一结构的单一系统,而是一个复杂的模块化结构层次。此外,由于生物组织中信号传播速度的限制,大脑的结构强制执行局部性原则,当大脑中几何相邻的结构处理相关任务时。这一自然系统的方面没有逃过神经进化研究者的注意,他们已经在许多进化算法中实现了这一点。我们将在第8章ES-HyperNEAT和视网膜问题中讨论如何使用基于神经进化的算法创建模块化人工神经网络。

NEAT算法概述

NEAT用于进化复杂人工神经网络的方法旨在通过在进化过程中逐步细化人工神经网络的结构来减少参数搜索空间的维度。进化过程从一个小型、简单的基因组(种子)种群开始,并在每一代中逐渐增加其复杂性。

种子基因组具有一个非常简单的拓扑结构:仅表达输入、输出和偏置神经元。从一开始就没有引入隐藏节点,以确保解决方案的搜索从可能的最低维参数空间(连接权重)开始。在每一代中,都会引入新的基因,通过呈现一个之前不存在的新维度来扩展解决方案搜索空间。因此,进化开始于一个小型空间,可以轻松优化,并在必要时添加新维度。采用这种方法,可以逐步、逐步地发现复杂的表型(解决方案),这比直接在最终解决方案的广阔空间中启动搜索要高效得多。自然进化通过偶尔添加使表型更复杂的基因来利用类似的策略。在生物学中,这个过程被称为复杂化

NEAT方法的主要目标是最小化基因组结构的复杂性——不仅是最終產品,还包括所有中间代有机体的结构。因此,网络拓扑结构的进化通过减少搜索空间的整体解决方案来带来显著的性能优势。例如,最终解决方案的高维空间仅在进化过程的最后阶段遇到。算法的另一个基本特征是,引入基因组的每个结构都将成为未来代际中后续适应性评估的主题。此外,在进化过程中,只有有用的结构才能生存下来。换句话说,基因组的结构复杂性始终是目标合理的。

NEAT编码方案

NEAT的遗传编码方案设计用于在交叉操作应用于两个父代基因组时,允许在配对过程中轻松匹配相应的基因。NEAT基因组是编码神经网络连接模式的线性表示,如下所示NEAT基因组方案:

图片

NEAT基因组方案

每个基因组表示为连接基因的列表,这些连接基因编码神经网络节点之间的连接。此外,还有节点基因,这些基因编码有关网络节点信息,例如节点标识符、节点类型和激活函数类型。连接基因编码网络链接的以下连接参数:

  • 输入网络节点的标识符

  • 输出网络节点的标识符

  • 连接的强度(权重)

  • 一个位,表示连接是否启用(表达)

  • 一个创新号,允许在重组过程中匹配基因

前一个图的下部表示同一基因组以有向图形式呈现的方案。

结构突变

特定于NEAT的突变操作可以改变连接的强度(权重)和网络的结构。主要有两种结构突变类型:

  • 在节点之间添加新的连接

  • 在网络中添加新的节点

下图显示了NEAT算法的结构突变:

图片

NEAT算法的结构突变

当突变操作应用于NEAT基因组时,新添加的基因(连接基因或节点基因)被分配一个不断增加的创新号。在进化过程中,种群中生物的基因组逐渐变大,产生了不同大小的基因组。这个过程导致不同的连接基因在基因组中的相同位置,使得同源基因之间的匹配过程极其复杂。

带有创新号的交叉

在进化过程中存在一些未被充分利用的信息,它告诉我们如何精确匹配任何在拓扑多样性种群中生物的基因组之间的基因。这正是每个基因告诉我们该基因是从哪个祖先那里衍生出来的。具有相同历史起源的连接基因代表相同的结构,尽管可能具有不同的连接权重值。NEAT算法中基因的历史起源由递增分配的创新号表示,这使我们能够追踪结构突变的年代学。

同时,在交叉过程中,后代继承了来自父母基因组的基因创新编号。因此,特定基因的创新编号永远不会改变,这使得来自不同基因组的相似基因在交叉过程中可以匹配。匹配基因的创新编号是相同的。如果创新编号不匹配,该基因属于基因组的不连续多余部分,这取决于其创新编号是否位于其他父母创新编号的范围之内或之外。不连续或多余的基因代表在另一父母基因组中不存在的结构,在交叉阶段需要特殊处理。因此,后代继承了具有相同创新编号的基因。这些基因是从父母之一随机选择的。后代总是从适应度最高的父母那里继承不连续或多余的基因。这一特性允许NEAT算法使用线性基因组编码有效地执行基因重组,而无需进行复杂的拓扑分析。

以下图表展示了NEAT算法中的交叉(重组):

图片

NEAT算法中的交叉(重组)

上述图表展示了使用NEAT算法的两个父母之间的交叉示例。两个父母的基因组通过创新编号(连接基因细胞顶部的数字)对齐。之后,当创新编号相同时,通过从任一父母随机选择连接基因来产生后代:编号为一到五的基因。最后,无条件地从任一父母那里添加不连续和多余的基因,并按创新编号排序。

物种分化

在进化过程中,生物体可以通过代际繁衍创造出多样的拓扑结构,但它们无法产生并维持自身的拓扑创新。较小的网络结构比较大的网络结构优化得更快,这人为地减少了在基因组中添加新节点或连接后后代基因组的生存机会。因此,新增加的拓扑结构由于种群中生物体适应度的暂时下降而承受着负面的进化压力。同时,新的拓扑结构可以引入创新,最终导致长期的成功解决方案。为了解决适应度的暂时下降,NEAT算法中引入了物种分化的概念。物种分化通过引入狭窄的生态位来限制可以交配的生物体范围,在这些生态位中,只有属于同一生态位的生物体在交叉过程中相互竞争,而不是与种群中的所有生物体竞争。物种分化通过将种群分割,使得具有相似拓扑结构的生物体属于同一物种来实现。

让我们参考以下物种分化算法:

图片

物种分化算法

NEAT方法允许创建复杂的ANN,能够解决各种控制优化问题,以及其他无监督学习问题。由于引入了通过复杂化和物种分化来增强ANN拓扑结构的特定细节,解决方案往往优化了训练和推理的性能。结果,ANN拓扑结构增长以匹配需要解决的问题,而没有通过传统的ANN拓扑设计方法引入任何多余的隐藏层。

关于NEAT算法的更多详细信息,请参阅原始论文:http://nn.cs.utexas.edu/downloads/papers/stanley.phd04.pdf

基于超立方体的NEAT

智力是大脑的产物,而人类大脑作为一种结构,本身也是自然进化的产物。这样一个复杂的结构在数百万年的演变过程中,在恶劣环境的压力下,以及在与其他生物为生存而竞争的过程中逐渐形成。因此,一个极其复杂的结构已经形成,具有许多层次、模块以及神经元之间数万亿的连接。人类大脑的结构是我们的指南星,正帮助我们努力创造人工智能系统。然而,我们如何用我们不完美的工具来应对人类大脑的复杂性呢?

通过研究人类大脑,神经科学家发现,其空间结构在所有感知和认知任务中起着至关重要的作用——从视觉到抽象思维。已经发现了许多复杂的几何结构,例如帮助我们进行惯性导航的网格细胞,以及与眼睛视网膜相连的皮层柱,用于处理视觉刺激。已经证明,大脑的结构使我们能够通过由输入中的特定模式激活的特定神经网络结构,有效地对从感官接收到的信号中的模式做出反应。这种大脑的特性允许它以极其高效的方式表示和处理从环境中获得的所有输入数据的多样性。我们的头脑已经进化成有效的模式识别和模式处理引擎,积极重用特定的神经网络模块来处理特定的模式,从而大大减少了所需的不同神经网络结构的数量。这仅由于复杂的模块化层次和其各个部分的空間整合才成为可能。

正如我们之前提到的,生物大脑包含了复杂的分层和空间感知数据处理程序。这激发了神经进化研究人员在人工神经网络领域引入类似的数据处理方法。在设计此类系统时,必须解决以下问题:

  • 需要大规模ANN的大量输入特征和训练参数

  • 有效表示在物理世界中观察到的自然几何规律和对称性

  • 通过引入局部性原理有效地处理输入数据,即当空间/语义相邻的数据结构由相互连接的神经单元模块处理时,这些模块占据整个网络结构的相同紧凑区域

在本节中,你了解了基于超立方的神经进化拓扑增强HyperNEAT)方法,该方法由Kenneth O. Stanley提出,通过利用几何规律来解决各种问题。在下一节中,我们将探讨组合模式生成网络CPPNs)。

组合模式生成网络

HyperNEAT通过引入一种新的间接基因组编码方案CPPNs扩展了原始的NEAT算法。这种编码方式使得将表型ANN的连接模式表示为其几何形状的函数成为可能。

HyperNEAT将表型神经网络的连接模式存储为一个四维超立方体,其中每个点编码两个节点之间的连接(即源神经元和目标神经元的坐标)以及连接的CPPN在其内部绘制各种图案。换句话说,CPPN计算一个四维函数,其定义如下:

图片

在这里,源节点位于(x[1], y[1]),目标节点位于(x[2], y[2])。在这个阶段,CPPN为表型网络中每个节点之间的每个连接返回一个权重,这以网格的形式表示。按照惯例,如果CPPN计算出的连接权重的大小小于一个最小阈值(w[min]),则不表示两个节点之间的连接。这样,CPPN产生的连接模式可以表示任何网络拓扑。连接模式可以通过在训练数据中发现规律来编码大规模ANN,并且可以重用同一组基因来编码重复。按照惯例,CPPN产生的连接模式被称为基质

下图展示了基于超立方的几何连接模式的解释:

图片

基于超立方的几何连接模式解释

与传统的ANN架构不同,CPPN为其隐藏节点使用一组各种激活函数来探索各种几何规律。例如,三角函数的正弦可以用来表示重复,而高斯函数可以用来在网络的特定部分强制局部性(即沿坐标轴的对称性)。因此,CPPN编码方案可以以紧凑的方式表示具有不同几何规律的图案,如对称性、重复、具有规律的重复等。

底板配置

CPPN连接到底板中的网络节点的布局可以采取各种形式,最适合不同类型的问题。选择合适的布局以实现最佳性能是实验者的责任。例如,控制六腿爬行器等径向实体的输出节点可能最好采用径向几何布局,以便可以用极坐标表示连接模式。

下图展示了底板布局配置的一些示例:

图片

底板布局配置示例

HyperNEAT通常使用几种常见的底板布局类型(参见前面的图),以下是一些例子:

  • 二维网格:以(0, 0)为中心的二维笛卡尔空间中的网络节点规则网格

  • 三维网格:以(0, 0, 0)为中心的三维笛卡尔空间中的网络节点规则网格

  • 状态空间三明治:两个二维*面网格,其中源节点和目标节点可以相互发送连接

  • 圆形:适合定义基于极坐标的径向几何规律的规则径向结构

进化连接CPPN和HyperNEAT算法

这种方法被称为HyperNEAT,因为它使用修改后的NEAT来进化表示超空间中空间模式的CPPNs。每个由超立方体界定的模式表达点,代表低维图中两个节点之间的连接(底板)。因此,超空间的维度是底层低维图维度的两倍。在第8章ES-HyperNEAT和视网膜问题中,我们将探讨一些使用二维连接模式示例。

HyperNEAT算法如下图所示:

图片

HyperNEAT算法的一般形式

在其进化过程中添加到连接CPPN中的任何连接基因或节点基因都会导致在表型基板上的连接模式中找到新的全局变化维度(新特性)。对CPPN基因组所做的任何修改都代表了一种全新的整个连接模式可以变化的方式。此外,先前进化的连接CPPN可以被查询以产生比用于其训练的更高分辨率的基板连接模式。这使得我们能够在任何分辨率下产生相同问题的有效解决方案,可能没有上限。因此,上述特性使HyperNEAT成为进化大规模生物启发式人工神经网络的有力工具。

如需了解更多关于HyperNEAT方法的信息,您可以参考以下链接:https://eplex.cs.ucf.edu/papers/stanley_alife09.pdf.

可进化基板HyperNEAT

HyperNEAT方法揭示了自然世界的几何规律可以通过放置在特定空间位置的人工神经网络节点得到充分表示。这样,神经进化获得了显著的好处,并允许大规模ANN用于高维问题,这在普通的NEAT算法中是不可能的。同时,HyperNEAT方法受到自然大脑结构的启发,但仍然缺乏自然进化过程的可塑性。虽然允许进化过程在节点之间阐述各种连接模式,但HyperNEAT方法在节点放置位置上暴露了一个硬限制。实验者必须从一开始就定义网络节点的布局,任何研究人员做出的错误假设都会降低进化过程的表现。

通过将网络节点放置在基板上的特定位置,实验者对由CPPN产生的权重模式施加了无意中的约束。这种限制随后干扰了CPPN,当它试图将自然世界的几何规律编码到产生解决方案的ANN(表型)的地形时。在这里,由CPPN产生的连接模式必须与实验者定义的基板布局完美对齐;只有给定网络节点之间才能建立连接。这种限制导致不必要的*似误差,从而破坏了结果。对于CPPN来说,在放置位置略有不同的节点上详细阐述连接模式可能更有效。

超立方体中的信息模式

为什么一开始就要对节点的位置施加这样的限制?如果从连接模式中提取的隐含线索成为放置下一个节点以更好地表示物理世界自然规律的位置指南,那岂不是很好?

具有均匀连接权重的区域编码的信息量很少,因此功能价值不大。同时,具有巨大权重值梯度的区域信息密集度极高。这些区域可以通过放置额外的网络节点来受益,以表示对自然过程的更精细编码。如您从我们对HyperNEAT算法的讨论中回忆的那样,可以在四维超立方体中用一个点来表示基板中两个节点之间的连接。因此,所提出的ES-HyperNEAT算法的主要特征是在检测到连接权重高变动的超立方体区域中表达更多的超点。同时,在连接权重变动较低的区域中放置较少的超点。

节点的放置以及它们之间暴露的连接可以由进化CPPN为基板给定区域产生的连接权重变化来决定。换句话说,除了从编码网络连接模式的CPPN中接收到的信息之外,不需要额外的信息来决定基板中下一个节点的放置。信息密度成为算法确定基板地形的主要指导原则。

表型ANN中的节点放置表示信息编码在由CPPN创建的连接模式中。

四叉树作为有效的信息提取器

为了表示在超立方体中编码连接权重的超点,ES-HyperNEAT算法采用了一个四叉树。四叉树是一种树形数据结构,其中每个内部节点恰好有四个子节点。这种数据结构被选中是因为其固有的特性,允许它在不同粒度级别上表示二维区域。使用四叉树,可以通过将任何感兴趣的区域分割成四个子区域来有效地组织二维空间中的搜索,每个子区域成为树的叶子节点,根(父)节点代表原始(分解)区域。

使用基于四叉树的信息提取方法,ES-HyperNEAT方法迭代地在基板ANN的二维空间中寻找节点之间新的连接,从实验者预先定义的输入和输出节点开始。这种方法比直接在四维超立方体空间中搜索计算上更有效。

下图展示了使用四叉树数据结构提取信息的一个示例:

基于四叉树的信息提取示例

基于四叉树的搜索算法在两个主要阶段运行:

  1. 划分和初始化:在这个阶段,通过递归细分初始底物空间(从(-1, -1)到(1, 1)的区域)来创建四叉树。细分在达到所需的树深度时停止。这隐式地确定了有多少子空间适合底物的初始空间(初始化分辨率)。之后,对于每个以  为中心的四叉树节点,使用  个参数查询CPPN以找到连接权重。当找到特定四叉树节点  的叶节点的连接权重时,可以使用以下公式计算该节点的方差:

这里  是叶节点之间*均连接权重,而  是到特定叶节点的连接权重。计算出的方差值是特定底物区域存在信息的启发式指标。如果这个值高于特定的划分阈值(定义所需信息密度),则可以对底物的相应*方区域重复划分阶段。这样,算法可以通过这种方式强制执行所需的信息密度。查看前一个图的顶部部分,以了解如何使用四叉树数据结构进行划分和初始化的视觉洞察。

  1. 修剪和提取:为了保证更多连接(以及底物中的节点)在信息密度高(权重方差高)的区域表达出来,修剪和提取过程是在前一个阶段生成的四叉树上执行的。四叉树深度优先遍历,直到当前节点的方差小于方差阈值  或者直到节点没有子节点(零方差)。对于每个合格的节点,连接在其中心  和每个父节点之间表达,父节点要么由实验者定义,要么在前两个阶段的运行中找到(即,从ES-HyperNEAT方法已经创建的隐藏节点)。参考前一个图的底部部分,以了解修剪和提取阶段的工作原理。

ES-HyperNEAT算法

ES-HyperNEAT算法从用户定义的输入节点开始,并详细探索从它们到新表达的隐藏节点的连接。在基板空间内表达输出连接模式和隐藏节点位置使用的是我们之前描述的四叉树信息提取方法。信息提取过程是迭代应用的,直到达到所需的信息表达密度水*,或者直到在超立方体中不能再发现更多信息。之后,通过表达到输出的输入连接模式,将得到的网络连接到用户定义的输出节点。我们也为此使用了四叉树信息提取。只有那些有路径连接到输入和输出节点的隐藏节点被保留在最终的网络中。

现在,我们在表型ANN的基板中定义了许多节点和连接。引入一个额外的带修剪处理阶段来从网络中移除一些节点可能是有益的。在这个阶段,我们只保留特定带内的点,并移除带边缘的点。通过使带变宽或变窄,CPPN可以管理编码信息的密度。有关带修剪的更多详细信息,请参阅ES-HyperNEAT论文 (https://eplex.cs.ucf.edu/papers/risi_alife12.pdf)。

看看下面的ES-HyperNEAT算法:

图片

ES-HyperNEAT算法

ES-HyperNEAT算法继承了NEAT和HyperNEAT方法的所有优点,并引入了更多更强大的新特性,包括以下内容:

  • 自动在基板内放置隐藏节点,以精确匹配由进化出的CPPN所表达的联系模式。

  • 由于其固有的能力,即通过初始CPPN架构的具体设计,以局部性偏向开始进化搜索,这使得我们能够更容易地产生模块化表型ANN。

  • 使用ES-HyperNEAT,可以在进化过程中通过增加基板中的节点和连接数量来详细阐述现有的表型ANN结构。这与HyperNEAT相反,在HyperNEAT中,基板节点的数量是预定义的。

ES-HyperNEAT算法允许我们使用原始的HyperNEAT架构,而不改变NEAT部分的遗传结构。它使我们能够解决由于在事先创建适当的基板配置方面的困难,而难以用HyperNEAT算法解决的问题。

关于ES-HyperNEAT算法及其背后的动机的更多详细信息可以在https://eplex.cs.ucf.edu/papers/risi_alife12.pdf找到。

新颖性搜索优化方法

大多数机器学习方法,包括进化算法,都是基于目标函数的优化进行训练的。目标函数优化方法背后的主要关注点是,提高求解器性能的最佳方式是奖励它们接*目标。在大多数进化算法中,接*目标是通过求解器的适应度来衡量的。一个生物体的性能是通过适应度函数来定义的,这是生物体适应其环境的进化压力的隐喻。根据这一范式,最适应的生物体更适合其环境,并且最适合找到解决方案。

虽然直接适应度函数优化方法在许多简单情况下效果良好,但对于更复杂的任务,它往往陷入局部最优的陷阱。收敛到局部最优意味着在搜索空间中的任何局部步骤在适应度函数优化过程中都不会提供任何改进。传统的遗传算法使用变异和岛屿机制来逃离这种局部最优。然而,正如我们在本书后面的实验中所发现的那样,它可能并不总是有助于欺骗性问题,或者可能需要太长时间才能找到成功的解决方案。

许多现实世界的问题具有这样的欺骗性适应度函数景观,无法通过仅基于测量当前解决方案与目标接*程度的优化过程来解决。例如,我们可以考虑在具有不规则街道模式的未知城市中导航的任务。在这样的任务中,朝着目的地前进通常意味着沿着欺骗性的道路行驶,这些道路只会让你离目的地越来越远,直到经过几次转弯后才到达。但如果你决定从指向目的地的道路开始,这通常会让你走到死胡同,而目的地就在墙的另一边,却无法触及。

新颖性搜索与自然进化

通过观察自然选择在物理世界中的运作方式,我们可以看到,进化多样性的背后推动力是对新颖性的追求。换句话说,任何正在进化的物种通过发现新的行为模式,都能立即获得相对于其竞争对手的进化优势。这使得它们能够更有效地利用环境。自然进化没有明确的目标,它通过奖励对新行为的探索和利用来扩大解决方案的搜索空间。这种新颖性可以被视为自然界中许多隐藏的创造力的代理,这使得进化能够进一步细化更复杂的行为和生物结构。

受自然进化的启发,乔尔·莱曼提出了一种用于人工进化过程的新颖性搜索优化方法。使用这种方法,没有定义或使用特定的适应度函数来搜索解决方案;相反,在神经进化过程中,每个找到的解决方案的新颖性直接得到奖励。因此,找到的解决方案的新颖性指导神经进化达到最终目标。这种方法使我们有机会利用进化的创造力,而无需适应压力将解决方案适应到特定的生态位。

新颖性搜索的有效性可以通过迷宫导航实验来证明,其中基于目标的搜索在简单迷宫中找到解决方案所需的步骤(代数)比新颖性搜索多得多。此外,对于具有欺骗性配置的困难迷宫,基于目标的搜索甚至无法找到任何解决方案。我们将在第5章 自主迷宫导航中讨论迷宫导航实验。

新颖性度量

新颖性搜索方法使用新颖性度量来跟踪每个新个体的行为独特性。也就是说,新颖性度量是衡量新生物体在行为空间中相对于其他个体的距离的度量。一个有效的新颖性度量实现应该允许我们在行为空间的任何点上计算稀疏性。任何有更密集的访问点集群的区域都相对不那么新颖,并产生较少的进化奖励。

在行为空间中,一个点稀疏性的最直接度量是该点k个最*邻的*均距离。当这个距离较高时,感兴趣的点位于稀疏区域。同时,密集区域以较低的距离值标记。因此,点 的稀疏性 由以下公式给出:

在这里, 是根据距离度量 计算的 的第i个最*邻。距离度量是两个个体之间行为差异的特定领域度量。

来自稀疏区域的候选个体获得更高的新颖性评分。当这个评分超过某个最小阈值时!,该位置的个体将被添加到表现最佳者的存档中,这些最佳表现者表征了行为空间中先前解决方案的分布。当前种群代与存档一起定义了搜索已经进行过的地方以及现在所在的位置。因此,通过最大化新颖性指标,搜索的梯度被引导向新的行为,而不需要任何明确的目标。然而,新颖性搜索仍然由有意义的信息驱动,因为探索新的行为需要全面利用搜索域。

以下图像展示了新颖性搜索算法:

新颖性搜索算法

新颖性搜索优化方法允许进化在任意欺骗空间中搜索解决方案并找到最优解。使用这种方法,当种群被迫不在特定利基解决方案(局部最优)中收敛,而必须探索整个解决方案空间时,可以实现发散进化。尽管其方法反直觉,完全忽略了搜索过程中的明确目标,但它似乎是一种非常有效的搜索优化方法。此外,它可以在大多数情况下比测量适应度作为最终解决方案距离的传统基于目标的搜索更快地找到最终解决方案。

如需更多详细信息,请参阅以下链接: http://joellehman.com/lehman-dissertation.pdf

摘要

在本章中,我们首先讨论了用于训练人工神经网络的多种方法。我们考虑了基于传统梯度下降的方法与基于神经进化的方法之间的区别。然后,我们介绍了一种最流行的神经进化算法(NEAT)以及我们可以扩展它的两种方式(HyperNEAT和ES-HyperNEAT)。最后,我们描述了搜索优化方法(新颖性搜索),它可以找到传统基于目标的搜索方法无法解决的多种欺骗问题的解决方案。现在,在设置必要的环境之后,你就可以将所学知识付诸实践了,我们将在下一章中讨论这一点。

在下一章中,我们将介绍可用的库,以便我们可以在Python中进行神经进化的实验。我们还将演示如何设置工作环境以及Python生态系统中可用于管理依赖项的工具。

进一步阅读

为了更深入地理解本章讨论的主题,请查看以下链接:

第三章:Python库和环境设置

本章介绍了我们可以使用的Python库,以便实现上一章中描述的神经进化算法。我们还将讨论每个库的优缺点。此外,我们还将提供基本的使用示例。然后,我们将考虑如何设置本书后面将要进行的实验环境,并检查在Python生态系统中执行此操作的常见方法。最后,我们将演示如何使用Anaconda Distribution设置一个工作环境,这是数据科学家中用于管理Python依赖项和虚拟环境的流行工具。在本章中,你将学习如何开始使用Python来实验本书中将要涵盖的神经进化算法。

在本章中,我们将涵盖以下主题:

  • 适用于神经进化实验的Python库

  • 环境设置

适用于神经进化实验的Python库

Python编程语言是机器学习和人工智能领域相关活动以及该领域研发中最受欢迎的语言之一。最突出的框架要么是用Python编写的,要么提供相应的接口。这种流行度可以归因于Python的学习曲线短,以及它作为可脚本化语言的本性,这使得实验可以快速进行。因此,遵循机器学习社区的一般趋势,一些支持神经进化的库是用Python编写的,并且随着时间的推移,库的数量持续增长。在本节中,我们将查看适用于进化算法领域的最稳定的Python库。

NEAT-Python

正如其名所示,这是通过Python编程语言实现的NEAT算法。NEAT-Python库提供了标准NEAT方法,用于种群中生物体基因组的遗传进化。它实现了将生物体的基因型转换为表型(人工神经网络)的实用程序,并提供方便的方法来加载和保存基因组配置以及NEAT参数。此外,它还实现了有用的例程,以便它可以收集关于进化过程执行状态的统计信息,并提供一种从给定的检查点恢复执行的方法。检查点允许我们定期保存进化过程的状态,并在稍后从保存的检查点数据中恢复过程的执行。

NEAT-Python算法的优点如下:

  • 它有一个稳定的实现。

  • 它有全面的文档。

  • 它可以通过PIP包管理器轻松安装。

  • 它具有内置的统计信息收集和存储执行检查点的支持,以及从给定检查点恢复执行的能力。

  • 它提供了多种激活函数类型。

  • 它支持连续时间循环神经网络表型。

  • 它可以轻松扩展以支持各种 NEAT 修改。

NEAT-Python 算法的缺点如下:

  • 默认情况下仅实现了 NEAT 算法。

  • 目前它处于仅维护状态,最*没有进行任何活跃的开发。

NEAT-Python 使用示例

以下是如何使用 NEAT-Python 库的一般示例,没有特定的问题。它描述了典型的步骤以及如何获得必要的结果。我们将在这本书中广泛使用这个库。您可以跳到下一章以获取具体的用法示例,但您应该阅读到本章的结尾,以了解更多关于替代库的信息。让我们开始吧:

  1. 加载 NEAT 设置和初始基因组配置:
config = neat.Config(neat.DefaultGenome, neat.DefaultReproduction, neat.DefaultSpeciesSet, neat.DefaultStagnation, config_file)

这里,config_file 参数指向包含 NEAT-Python 库设置和初始基因组默认配置的文件。

  1. 从配置数据创建生物体种群:
p = neat.Population(config)
  1. 添加统计报告器和检查点收集器:
# Output progress to the stdout
p.add_reporter(neat.StdOutReporter(True)) 
stats = neat.StatisticsReporter()
p.add_reporter(stats)
p.add_reporter(neat.Checkpointer(5))
  1. 在特定数量的代数(在我们的例子中是 300)上运行进化过程:
winner = p.run(eval_genomes, 300)

这里,eval_genomes 是一个函数,用于评估种群中所有生物体的基因组与特定适应度函数,而 winner 是找到的最佳表现型基因型。

  1. 可以如下从基因组创建表型 ANN:
winner_ann = neat.nn.FeedForwardNetwork.create(winner, config)
  1. 之后,可以使用输入数据查询 ANN 以计算结果:
for xi in xor_inputs: 
    output = winner_ann.activate(xi)
    print(xi, output) # print results

该库可在 https://github.com/CodeReclaimers/neat-python 获取。

以下源代码旨在让您了解库的功能。完整的代码示例将在后续章节中提供。

PyTorch NEAT

该库是基于 NEAT-Python 库构建的。它为 NEAT-Python 库生成的工件与 PyTorch *台提供了易于集成的功能。因此,可以将 NEAT 基因组转换为基于 PyTorch 实现的循环神经网络(ANN)表型。此外,它还允许我们将组合模式生成网络CPPNs)表示为 PyTorch 结构,这是 HyperNEAT 方法的主要构建块。与 PyTorch 集成的主要优势是,它使我们能够利用 GPU 进行计算,从而可能由于评估进化种群中生物体基因组的速度提高而加速进化过程。

PyTorch NEAT 的优点如下:

  • 它基于稳定的 NEAT-Python 库,这使得我们能够利用其所有优势。

  • 与 PyTorch 框架的集成。

  • 它对 NEAT 基因组的评估进行了 GPU 加速。

  • 它包括 CPPN 实现,这是 HyperNEAT 算法的构建块。

  • 与 OpenAI GYM 环境的集成。

PyTorch NEAT 的缺点如下:

  • 仅完全实现了NEAT算法。

  • 它只提供了HyperNEAT算法实现的有限支持。

更多关于OpenAI GYM的详细信息,请访问https://gym.openai.com

PyTorch NEAT使用示例

以下是用PyTorch NEAT库实现倒立摆*衡控制器的示例。这只是一个概述。本书后面将更详细地探讨倒立摆*衡问题。让我们开始吧:

  1. 加载NEAT设置和种子基因组配置:
config = neat.Config(neat.DefaultGenome, neat.DefaultReproduction, 
      neat.DefaultSpeciesSet, neat.DefaultStagnation, config_file)

在这里,config_file文件存储了NEAT算法设置以及默认的基因组配置。

  1. 从配置数据创建生物种群:
pop = neat.Population(config)
  1. 基于PyTorch和OpenAI GYM准备多环境基因组评估器:
def make_env(): 
    return gym.make("CartPole-v0")

def make_net(genome, config, bs): 
    return RecurrentNet.create(genome, config, bs)

def activate_net(net, states): 
    outputs = net.activate(states).numpy()
    return outputs[:, 0] > 0.5

evaluator = MultiEnvEvaluator( 
    make_net, activate_net, make_env=make_env, 
    max_env_steps=max_env_steps
)

def eval_genomes(genomes, config): 
    for _, genome in genomes:
        genome.fitness = evaluator.eval_genome(genome, config)

在这里,对gym.make("CartPole-v0")函数的调用是调用OpenAI GYM框架以创建单个倒立摆*衡环境。

  1. 添加统计和日志报告器:
stats = neat.StatisticsReporter()
pop.add_reporter(stats)
reporter = neat.StdOutReporter(True)
pop.add_reporter(reporter)
logger = LogReporter("neat.log", evaluator.eval_genome)
pop.add_reporter(logger)
  1. 在特定的代数(在我们的例子中是100)上运行进化过程:
winner = pop.run(eval_genomes, 100)

在这里,eval_genomes是一个用于评估种群中所有生物的基因组的函数,而winner是找到的最佳表现型基因型。

  1. 可以从基因组创建表型ANN,如下面的代码所示:
winner_ann = RecurrentNet.create(genome, config, bs)

在这里,genome是NEAT基因组配置,config是一个封装NEAT设置的对象,而bs是一个指示所需批量大小的参数。

  1. 之后,可以使用输入数据查询ANN以获得结果:
action = winner_ann.activate(states).numpy()

在这里,action是用于模拟的动作指定符,而states是包含从模拟器获得的当前环境状态的张量。

图书馆的源代码可在https://github.com/uber-research/PyTorch-NEAT找到。

上述源代码是为了让你对库有一个感觉。完整的代码示例将在后续章节中提供。

MultiNEAT

MultiNEAT是本书中我们将讨论的库中最通用的库,因为它支持标准的NEAT算法和两个关键扩展:HyperNEAT和ES-HyperNEAT。此外,MultiNEAT库还提供了一个新颖性搜索优化方法的实现。该库是用C++编程语言编写的,但提供了一个全面的Python接口。MultiNEAT Python wheel也通过Anaconda包管理器提供,这使得在任何操作系统上安装和使用变得容易。

MultiNEAT库的优点如下:

  • 稳定实现

  • 实现了NEAT家族的多种算法,如下所示:

    • NEAT

    • HyperNEAT

    • ES-HyperNEAT

  • 提供了新颖性搜索优化方法的实现

  • 通过Hebbian学习支持可塑神经网络

  • 通过Python中的OpenCV提供基因型和表型的可视化

  • 与OpenAI GYM环境的集成

  • 完整的文档

MultiNEAT库的缺点如下:

  • 没有GPU支持

  • 不支持检查点

MultiNEAT使用示例

以下是一个使用MultiNEAT库通过神经进化实现XOR求解器的示例。这只是一个概述,没有实现XOR适应度评分评估器(evaluate_xor),这将在下一章中讨论。让我们开始吧:

  1. 创建NEAT配置设置:
params = NEAT.Parameters()
params.PopulationSize = 100
# The rest of the settings omitted for brevity
  1. 创建一个最小基因组配置并从这个基因组中生成一个生物种群:
g = NEAT.Genome(0, 3, 0, 1, False, 
      NEAT.ActivationFunction.UNSIGNED_SIGMOID,
      NEAT.ActivationFunction.UNSIGNED_SIGMOID, 0, params, 0)
pop = NEAT.Population(g, params, True, 1.0, i)
  1. 1000代或找到胜者之前运行进化过程:
for generation in range(1000):
    # Evaluate genomes
    genome_list = NEAT.GetGenomeList(pop)
    fitnesses = EvaluateGenomeList_Serial(genome_list, 
                            evaluate_xor, display=False)
    [genome.SetFitness(fitness) for genome, fitness in zip(genome_list, fitnesses)]

    # Evaluate fitness value against specific threshold
    best = max(fitness_list)
    if best > 15.0:
        # get the phenotype of a best organism
        net = NEAT.NeuralNetwork()
        pop.Species[0].GetLeader().BuildPhenotype(net)
        # return the fitness and phenotype ANN of the winner
        return (best, net)

    # Next epoch
    pop.Epoch()
  1. 以下是对查询胜者表型ANN的查询,以及一些输入以获取结果:
net.Input( [ 1.0, 0.0, 1.0 ] )
net.Activate()
output = net.Output()

你可以在https://github.com/peter-ch/MultiNEAT找到这个库。

以下源代码是为了让你对库有一个感觉。完整的代码示例将在接下来的章节中提供。

深度神经进化

深度神经网络DNNs)通过利用现代GPU的并行处理能力,在涉及模式识别和强化学习任务中表现出卓越的性能提升。在神经进化的背景下,探索传统的深度强化学习deep RL)方法如何与基于深度神经进化的方法进行比较特别有趣。为了回答这个问题,UberAI实验室的研究团队创建并发布了相应的Python编程语言库,该库使用TensorFlow框架处理与GPU设备上神经网络训练相关的计算。

该库提供了简单遗传算法GA)和新颖性搜索优化方法的实现。它还提供了进化策略方法的实现,这是一种另一种进化算法。

你可以在Hans-Georg Beyer的《进化策略理论》一书中找到更多关于进化策略方法的信息。Springer,2001年4月27日。

深度神经进化的优点如下:

  • 稳定实现

  • 通过与TensorFlow的集成启用GPU

  • 能够直接处理高维问题,例如直接从像素中学习行动的能力

  • 提供了新颖性搜索优化方法的实现

  • 无梯度方法优化DNNs

  • 通过神经进化视觉检查器VINE)提供学习过程的可视化

  • 提供与OpenAI GYM环境的集成

  • 提供与Atari游戏环境的集成

深度神经进化的缺点是它没有提供NEAT家族神经进化算法的实现,即NEAT、HyperNEAT和ES-HyperNEAT。

在Deep Neuroevolution库中实现的遗传算法控制着一群具有基因组编码深度神经网络学习参数(连接权重)向量的生物体的进化。在每一代中,每个基因型都会被评估并产生一个适应度分数。之后,从最适应的个体中随机选择特定数量的生物体作为下一代父母。然后,每个选定的父母生物体的基因型通过添加高斯噪声进行突变。此外,算法还使用了精英主义的概念,即从上一代中添加特定数量的最适应生物体到下一代,而不进行任何修改。在进化过程中不应用交叉算子以简化算法。该算法使用的DNN拓扑结构是固定的,由实验者手动设置。

让我们参考以下简单的遗传算法:

简单遗传算法

关于深度神经演化的实现细节,可在https://github.com/uber-research/deep-neuroevolution找到。

比较Python神经进化库

下表提供了本章中讨论的Python库之间的快速比较:

NEAT-Python PyTorch NEAT MultiNEAT Deep Neuroevolution
NEAT Yes Yes Yes No
HyperNEAT No Partial (CPPN only) Yes No
ES-HyperNEAT No No Yes No
Novelty Search No No Yes Yes
OpenAI GYM No Yes Yes Yes
Visualization No No Yes Yes
GPU support No Yes No Yes
PIP Yes No No No
Anaconda No No Yes No
Checkpoints Yes Yes No Yes

NEAT-Python库提供了优秀的可视化集成,并且易于使用。然而,它有一个显著的缺点,那就是它完全是用Python实现的,因此执行速度非常慢。它仅适用于简单的问题。

MultiNEAT Python库的核心是用C++实现的,这使得它的性能略优于NEAT-Python库。它可以用于解决需要创建更大表型ANN的更复杂任务。此外,它还提供了HyperNEAT和ES-HyperNEAT方法的实现,这使得它成为与训练大规模ANN相关的任务的正确选择。

Deep Neuroevolution库是最先进的神经进化实现,它允许我们利用GPU的力量来处理具有数百万可训练参数的训练任务。这在视觉图像处理领域是可行的。

在本书的后面部分,我们将更深入地了解每个Python库,并将它们付诸实践。

环境设置

在使用 Python 库时,正确设置工作环境至关重要。有许多依赖项,包括 Python 语言版本和系统中可用的二进制文件;所有这些都必须对齐并具有兼容的版本。因此,库和语言版本的冲突配置可以很容易地创建,这增加了挫败感和数小时的调试和错误修复。为了解决这个问题,Python 编程语言中引入了虚拟环境的概念。虚拟环境允许我们创建包含特定 Python 项目中使用的所有必要依赖项和可执行文件的隔离 Python 环境。这样的虚拟环境在不再需要时可以轻松创建和删除,而不会在系统中留下任何残留。

在众多用于处理 Python 虚拟环境的工具中,我们可以突出以下工具:

  • Pipenv

  • Virtualenv

  • Anaconda

Pipenv

Pipenv 是一个将包管理器与虚拟环境管理器结合在一起的工具。主要目标是使开发者能够轻松地为特定项目设置一个包含所有必要依赖项的独特工作环境。

可以使用以下命令通过 PIP(Python 的包安装器)进行安装:

$ pip install --user pipenv

上述命令将 pipenv 工具安装到用户空间,以防止它破坏任何系统范围的包。

要安装所有依赖项并为您的项目创建一个新的虚拟环境(如果尚不存在),请切换到项目的目录并运行安装过程,如下所示:

$ cd my_project_folder
$ pipenv install <package>

此命令在 my_project_folder 中创建一个新的虚拟环境并将 <package> 安装到其中。就是这样。

可以提供一个配置文件(Pipfile),指定应安装哪些包,以及与构建过程相关的其他信息。当您第一次运行 install 命令时,如果 Pipfile 还不存在,它将自动创建。

更多关于该工具的详细信息可以在 https://pipenv.kennethreitz.org/en/latest/ 找到

Virtualenv

Virtualenv 是一个从 Python 3.3 开始使用的用于创建隔离 Python 环境的工具,它部分集成到标准库的 venv 模块中。该工具解决的主要问题是独立维护每个 Python 项目的独特依赖项、版本和权限集合。Virtualenv 通过为每个项目创建一个具有自己的安装目录的独立环境来处理这个问题。这阻止了我们与其他项目共享任何依赖项和库。此外,还可以阻止对全局安装的库的访问。

Virtualenv 是一个纯虚拟环境管理器,它不提供任何包管理例程。因此,它通常与包管理器一起使用来管理项目的依赖项,例如 PIP。让我们看看 Virtualenv 的样子:

  1. 使用以下方式使用 PIP 安装 Virtualenv:
$ pip install virtualenv
  1. 测试安装是否成功:
$ virtualenv --version
  1. 使用以下命令为您项目创建虚拟环境:
$ cd my_project_folder
$ virtualenv venv

此命令在 my_project_folder 中创建一个新的虚拟环境。新的环境包括一个包含 Python 可执行文件的文件夹,以及 PIP 库的副本,这是一个允许我们安装其他依赖项的包管理器。

  1. 在开始使用之前,您需要使用以下命令激活虚拟环境,该命令可以输入到您选择的终端应用程序中:
$ source /path/to/ENV/bin/activate

在执行上述命令后,所有必要的环境变量都将设置为针对您项目特定的正确值,并且当前终端应用程序会话将使用它来执行任何后续输入的命令。

  1. 可以使用 PIP 将其他包轻松安装到活动环境中:
$ pip install sqlite

上述命令将 SQLite 包安装到当前活动环境中。

如果在 pip install 命令之后没有提供包名称,pip 管理器将在当前目录中查找 requirements.txt 文件以指定要安装的包。

您可以在 https://virtualenv.pypa.io/en/latest/ 找到更多详细信息。

Anaconda

Anaconda Distribution 是一个包和一个虚拟环境管理器,在数据科学家和机器学习专业人士中很受欢迎,因为它提供了对大量定制科学库(超过 1,500 个)和有用工具的便捷访问。除此之外,它还允许您在一个地方编写源代码并执行 Python 和 R 脚本。使用 Anaconda,您可以轻松创建、保存、加载和切换虚拟环境,以及将 Anaconda 团队审查和维护的数千个包安装到每个虚拟环境中。

要安装 Anaconda,您需要从 https://www.anaconda.com/distribution/ 下载适合您操作系统的安装程序。

之后,可以使用以下命令为您项目创建新的环境:

$ cd my_project_folder
$ conda create --name ENV_NAME <package>

上述命令为您项目创建一个新的虚拟环境,并将指定的包或多个包安装到其中。在激活后,可以轻松地将其他包安装到新环境中。

可以使用以下命令列出系统中可用的所有环境:

$ conda env list

任何现有环境都可以按以下方式激活:

$ conda activate ENV_NAME

要停用当前活动环境,请使用以下命令:

$ conda deactivate

可以通过标准 PIP 或使用 conda install 命令将额外的库安装到当前环境中:

$ conda install sqlite

执行上述命令后,SQLite 将被安装到当前活动环境中。

在本书中,我们将使用 Anaconda 来管理我们大多数项目的依赖关系和环境。

如果你想了解更多信息,请熟悉所有可用的 Anaconda 命令,请参阅 https://docs.conda.io/projects/conda/en/latest/commands.html

摘要

在本章中,我们学习了四个流行的 Python 库,我们可以使用这些库在神经进化领域进行实验。我们讨论了每个库的优点和缺点,并回顾了在 Python 中使用这些库的基本示例。之后,我们探讨了如何设置基于 Python 的实验环境,以避免 Python 路径中存在相同库的多个版本所带来的副作用。我们发现,为每个 Python 项目创建隔离的虚拟环境是最佳做法,并考虑了开源社区为帮助完成这项任务而创建的几个流行解决方案。最后,我们介绍了 Anaconda Distribution,它包括(但不仅限于)包管理器和环境管理器。在本书的剩余部分,我们将使用 Anaconda 正确处理实验中的环境设置。

在下一章中,我们将讨论如何使用 NEAT 算法来解决经典的计算机科学问题。你将使用本章中讨论的 NEAT-Python 库编写 XOR 问题求解器。我们还将讨论用于配置 NEAT 算法的超参数以及如何调整这些参数以提高神经进化过程的表现。

第四章:第2节:将神经演化方法应用于解决经典计算机科学问题

本节讨论了如何将基于神经演化的算法应用于解决经典计算机科学问题。在本节中,你将学习使用神经演化算法解决经典计算机科学问题所需的基本技术和技能。本节将为你准备与本书第三部分讨论的更高级技术一起工作。

本节包括以下章节:

第五章:使用NEAT进行XOR求解器优化

在本章中,您将了解一个经典的计算机科学实验,该实验证明了NEAT算法的有效性,并能创建合适的网络拓扑。在本章中,您将亲身体验编写一个目标函数来指导XOR问题求解器。您还将学习如何选择NEAT算法的正确超参数以帮助解决XOR问题。本章旨在向您介绍如何将NEAT算法应用于解决经典计算机科学问题的基本技术。

完成本章描述的实验和练习后,您将对XOR实验的细节有一个扎实的理解,并获得使用NEAT-Python库编写相关Python源代码所需的实际技能。您还将获得设置NEAT-Python库的超参数和使用可视化工具可视化实验结果的经验。之后,您将准备好开始尝试本书后面将要讨论的更复杂的问题。

在本章中,我们将涵盖以下主题:

  • XOR问题基础

  • 如何定义目标函数来指导XOR问题求解器

  • XOR实验的超参数选择

  • 运行XOR实验

技术要求

为了执行本章描述的实验,应满足以下技术要求:

  • Windows 8/10,macOS 10.13或更高版本,或现代Linux

  • Anaconda Distribution版本2019.03或更高版本

本章的代码可以在https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/tree/master/Chapter3找到。

XOR问题基础

在其拓扑中没有任何隐藏单元的经典多层感知器MLP)或人工神经网络ANN)只能正确解决线性可分问题。因此,这种ANN配置不能用于模式识别或控制和优化任务。然而,具有一些具有某种非线性激活函数(如sigmoid)的更复杂的MLP架构,可以*似任何函数到给定的精度。因此,非线性可分问题可以用来研究神经进化过程是否可以在求解器表型的ANN中增长任意数量的隐藏单元。

XOR问题求解器是强化学习领域的一个经典计算机科学实验,如果不引入非线性行为到求解器算法中,是无法解决的。该问题的解决方案搜索空间具有最小尺寸,可以用来证明NEAT算法可以从一个非常简单的ANN拓扑结构开始进化,逐渐增加复杂性,找到所有连接都正确布线的适当网络结构。通过展示NEAT算法能够持续地生长适当的拓扑结构,XOR实验也证明了NEAT可以避免适应度值景观中的局部最大值。局部最大值是一个陷阱,求解器可能会陷入其中,产生一个具有错误连接模式的局部冠军。之后,一个局部冠军可能会在种群中占据主导地位,以至于求解器无法解决问题。

下面是一个定义XOR特征的表格:

输入1 输入2 输出
1 1 0
1 0 1
0 1 1
0 0 0

XOR是一个二进制逻辑运算符,只有当两个输入中只有一个为真时才返回true。两个输入信号必须通过非线性隐藏单元组合,以产生正确的输出信号。没有线性函数能够正确地将XOR输入组合并分离到它们正确的类别中。

NEAT算法从初始种群开始,该种群编码了一个非常简单的表型,并逐渐进化表型的拓扑结构,直到创建一个合适的ANN。表型ANN的初始结构不包含任何隐藏单元,由两个输入单元、一个输出单元和一个偏置单元组成。两个输入节点和偏置节点连接到输出节点,即初始基因型有三个连接基因和四个节点基因。偏置单元是一种特殊的输入,它始终初始化为大于0的特定值(通常为1.0或0.5)。如果我们希望将神经元单元(输出或隐藏)的激活(通过应用于输入和偏置的总和的相关激活函数计算得出)设置为特定的非零值,那么偏置单元是必要的——如果两个输入的值都是0。

以下图表显示了初始和可能的最小XOR表型:

初始和最优的XOR表型

表型的人工神经网络(ANN)变得越来越复杂,直到通过包含一个或多个额外的隐藏节点找到最终解决方案。可能的最小求解器只包含一个隐藏节点,NEAT方法通过在更复杂的配置中找到最优求解器配置来展示其能力。

XOR实验的目标函数

在 XOR 实验中,种群中生物体的适应度定义为正确答案与为所有四个 XOR 输入模式生成的输出总和之间的*方距离。它按以下方式计算:

  1. 表型人工神经网络针对所有四个 XOR 输入模式进行激活。

  2. 将输出值从每个模式的正确答案中减去,然后将结果的绝对值相加。

  3. 在前一步找到的错误值从最大适应度值(4)中减去,以计算生物体的适应度。最高的适应度值意味着更好的求解器性能。

  4. 计算出的适应度值然后*方,以给生物体提供更多比例的适应度,从而产生给出更接*正确答案的求解器人工神经网络。这种方法使进化压力更加激烈。

因此,目标函数可以定义为以下:

基于 NEAT-Python 库的相应 Python 源代码如下:

# XOR inputs and expected output values
xor_inputs = [(0.0, 0.0), (0.0, 1.0), (1.0, 0.0), (1.0, 1.0)]
xor_outputs = [ (0.0,), (1.0,), (1.0,), (0.0,)]

def eval_fitness(net):
    error_sum = 0.0
    for xi, xo in zip(xor_inputs, xor_outputs):
        output = net.activate(xi)
        error_sum += abs(output[0] - xo[0])
    # Calculate amplified fitness
    fitness = (4 - error_sum) ** 2
    return fitness

注意,没有必要将适应度值归一化以适应 [0,1] 范围(如基于反向传播的方法那样),因为在训练过程中没有涉及反向梯度计算。生物体的适应度评分直接根据它们的绝对值进行比较。因此,值的范围无关紧要。

您还可以尝试不同的适应度评分计算方法的不同变体。例如,您可以实现一个类似于均方误差的函数,并比较算法针对不同目标函数实现的性能。唯一的要求是目标函数应该为更好的求解器产生更高的适应度评分。

超参数选择

本章我们将讨论的 XOR 实验使用 NEAT-Python 库作为框架。NEAT-Python 库定义了一组超参数,这些参数用于控制 NEAT 算法的执行和性能。配置文件存储的格式类似于 Windows .INI 文件;每个部分以方括号内的名称([部分])开始,后跟由等号(=)分隔的键值对。

在本节中,我们将讨论 NEAT-Python 库中的一些超参数,这些参数可以在配置文件的每个部分中找到。

NEAT-Python 库中所有超参数的完整列表可以在 https://neat-python.readthedocs.io/en/latest/config_file.html 找到。

NEAT 部分

本节指定了特定于 NEAT 算法的参数。本节包括以下参数:

  • fitness_criterion: 从种群中所有基因组的适应度值集中计算终止标准的函数。参数值是标准聚合函数的名称,例如min、max和mean。min和max值用于在种群的最小或最大适应度超过给定的fitness_threshold时终止进化过程。当值设置为mean时,种群的*均适应度用作终止标准。

  • fitness_threshold: 与适应度比较的阈值值,由fitness_criterion函数计算,以测试是否必须终止进化。

  • no_fitness_termination: 定义了由前面的参数定义的基于适应度的进化过程终止的标志。当设置为True时,进化过程只有在评估了最大代数后才会终止。

  • pop_size: 每一代中个体的数量。

  • reset_on_extinction: 当由于停滞导致当前代的所有物种灭绝时,控制是否创建新的随机种群的一个标志。如果设置为False,在完全灭绝时将抛出CompleteExtinctionException异常。

DefaultStagnation部分

此部分定义了由DefaultStagnation类实现的特定于物种停滞例程的参数。此部分包括以下参数:

  • species_fitness_func: 用于计算物种适应度的函数名称,即计算属于特定物种的所有生物的聚合适应度值。允许的值是max、min和mean。

  • max_stagnation: 在超过max_stagnation代数内没有显示出由species_fitness_func计算的适应度值提升的物种被认为是停滞的,并可能面临灭绝。

  • species_elitism: 无条件保护免受停滞影响的物种数量。其目的是防止在出现新物种之前种群完全灭绝。在种群中,具有最高适应度的指定数量的物种总是能够存活下来,尽管它们没有显示出进一步的适应度提升。

DefaultReproduction部分

此部分提供了由内置DefaultReproduction类实现的繁殖例程的配置。此部分包括以下参数:

  • elitism: 每个物种中最适应的个体数量,这些个体在下一代中无变化地复制。这个因素使我们能够保留在上一代中发现的任何有益突变。

  • survival_threshold: 每个物种中允许成为下一代父母(即有资格进行有性繁殖[交叉])的有机体比例。通过调整此值,可以定义允许参与繁殖过程的有机体的最低适应度分数。这是因为survival_threshold比例是从按适应度降序排列的有机体列表中取出的。

  • min_species_size: 繁殖周期后每个物种中保留的有机体最小数量。

DefaultSpeciesSet部分

本节提供了由内置DefaultSpeciesSet类实现的物种形成过程的配置,包括以下参数:

  • compatibility_threshold: 控制有机体属于同一物种(基因组距离小于此值)还是不同物种的阈值。较高值意味着进化过程具有较少的物种形成能力。

DefaultGenome部分

本节定义了用于创建和维护基因组(由DefaultGenome类实现)的配置参数。本节包括以下参数:

  • activation_default: 用于节点基因中使用的激活函数的名称。

  • activation_mutate_rate: 如果基因组支持多个激活函数(例如对于CPPN基因组),则这是突变替换当前节点激活函数为从支持函数列表中取出的新函数的概率(参见activation_options)。

  • activation_options: 可以由节点基因使用的激活函数的空间分隔列表。

  • aggregation_default: 网络节点在激活之前用于从其他节点接收到的任何聚合输入信号的默认聚合函数的名称。

  • aggregation_mutate_rate: 如果基因组支持多个聚合函数,则此参数定义了突变替换当前节点聚合函数为聚合函数列表中的新函数的概率(参见aggregation_options)。

  • aggregation_options: 可以由节点基因使用的聚合函数的空间分隔列表。支持值包括sum(求和)、min(最小值)、max(最大值)、mean(*均值)、median(中位数)和maxabs(最大绝对值)。

  • compatibility_threshold: 控制有机体属于同一物种(基因组距离小于此值)还是不同物种的阈值。较高值意味着进化过程具有较少的物种形成能力。

  • compatibility_disjoint_coefficient: 在基因组距离计算过程中使用的系数,用于计算不重叠或过剩基因对计算结果的影响。此参数的较高值放大了不重叠或过剩基因在基因组距离计算中的重要性。

  • compatibility_weight_coefficient:该系数管理节点基因的偏差和响应属性与连接基因的权重属性之间的基因组距离计算的差异对结果的影响。

  • conn_add_prob:引入新连接基因到现有节点基因之间的突变的概率。

  • conn_delete_prob:从基因组中删除现有连接基因的突变的概率。

  • enabled_default:新创建的连接基因的启用属性的默认值。

  • enabled_mutate_rate:切换连接基因启用属性的突变的概率。

  • feed_forward:控制生成过程中要生成的表型网络的类型。如果设置为True,则不允许循环连接。

  • initial_connection:指定新创建基因组的初始连接模式。允许的值包括unconnectedfs_neat_nohiddenfs_neat_hiddenfull_directfull_nodirectpartial_directpartial_nodirect

  • node_add_prob:添加新节点基因的突变的概率。

  • node_delete_prob:从基因组中删除现有节点基因及其所有连接的突变的概率。

  • num_hiddennum_inputsnum_outputs:初始种群基因组的隐藏节点、输入节点和输出节点的数量。

  • single_structural_mutation:如果设置为True,则在进化过程中只允许结构突变,即只允许节点或连接的添加或删除。

XOR实验超参数

XOR实验从一个非常简单的初始基因组配置开始,该配置只有两个输入节点、一个输出节点和一个特殊的输入——偏差节点。在初始基因组中不引入任何隐藏节点:

[DefaultGenome]
# The network parameters
num_hidden = 0
num_inputs = 2
num_outputs = 1

# node bias options
bias_init_mean = 0.0
bias_init_stdev = 1.0

所有网络节点的激活函数是S型,节点输入通过sum函数聚合:

[DefaultGenome]
# node activation options
activation_default = sigmoid

# node aggregation options
aggregation_default = sum

编码网络的类型是前馈全连接:

[DefaultGenome]
feed_forward = True
initial_connection = full_direct

在进化过程中,新的网络节点和连接以特定的概率被添加和/或删除:

[DefaultGenome]
# node add/remove rates
node_add_prob = 0.2
node_delete_prob = 0.2

# connection add/remove rates
conn_add_prob = 0.5
conn_delete_prob = 0.5

所有连接默认启用,由于突变而变为禁用的概率非常低:

[DefaultGenome]
# connection enable options
enabled_default = True
enabled_mutate_rate = 0.01

基因组距离高度受父代基因组多余/不连接部分的影响,以促进物种的多样性:

[DefaultGenome]
# genome compatibility options
compatibility_disjoint_coefficient = 1.0
compatibility_weight_coefficient = 0.5

物种停滞延长到20代,并部分防止独特物种灭绝:

[DefaultStagnation]
species_fitness_func = max
max_stagnation = 20
species_elitism = 2

物种内生物的生存阈值被设置为低值,以缩小进化过程,只允许最适应的生物繁殖(按适应性排序的生物列表的前20%)。同时,也引入了精英主义,无条件地将每个物种中两个最适应的个体复制到下一代。最小物种大小也影响物种形成,我们将其保留为默认值:

[DefaultReproduction]
elitism = 2
survival_threshold = 0.2
min_species_size = 2

物种兼容性阈值控制种群中物种的多样性。此参数的较高值导致种群具有更高的多样性。物种多样性应保持*衡,以保持进化过程按预期方向进行,避免探索过多的搜索向量,同时允许探索创新:

[DefaultSpeciesSet]
compatibility_threshold = 3.0

种群大小设置为150,这相当适中,但对于如此简单的XOR问题来说已经足够了。终止标准(fitness_threshold)设置为15.5,以确保当找到的解决方案与目标(根据我们的fitness函数,最大适应度分数为16.0)最接*时,进化终止。

在这个任务中,我们感兴趣的是找到能够解决XOR问题的进化冠军,因此我们的终止函数(fitness_criterion)是max函数,它从种群中的所有生物中选择最大适应度:

[NEAT]
fitness_criterion = max
fitness_threshold = 15.5
pop_size = 150
reset_on_extinction = False

完整的配置文件xor_config.ini包含在本章相关源文件存储库的Chapter3目录中。

我们只介绍了对NEAT算法性能有重大影响的超参数。这些超参数的值被测试以生成一个工作的XOR求解器,但请随意尝试并看看会发生什么。

运行XOR实验

在我们开始处理XOR实验之前,我们需要根据我们选择的NEAT-Python库的要求正确设置我们的Python环境。NEAT-Python库可在PyPI上找到,因此我们可以使用pip命令将其安装到XOR实验的虚拟环境中。

环境设置

在我们开始编写与XOR实验相关的代码之前,应该创建适当的Python环境,并将所有依赖项安装到其中。按照以下步骤正确设置工作环境:

  1. 使用Anaconda Distribution中的conda命令创建一个用于XOR实验的Python 3.5虚拟环境,如下所示:
$ conda create --name XOR_neat python=3.5

确保您的系统中已安装Anaconda Distribution,如第2章中所述,Python库和环境设置

  1. 要使用新创建的虚拟环境,您必须激活它:
$ conda activate XOR_neat
  1. 之后,可以使用以下命令将NEAT-Python库安装到活动环境中:
$ pip install neat-python==0.92 

我们在这里使用NEAT-Python库的特定版本(0.92),这是撰写时的最新版本。

  1. 最后,我们需要安装可视化工具使用的可选依赖项。这可以通过以下conda命令完成:
$ conda install matplotlib
$ conda install graphviz
$ conda install python-graphviz

现在,我们准备开始编写源代码。

XOR实验源代码

要开始实验,我们需要使用mkdir命令(适用于Linux和macOS)或md(适用于Windows)创建一个名为Chapter3的目录:

$ mkdir Chapter3

此目录将保存本章所述实验的所有相关源文件。

然后,我们需要将xor_config.ini文件从与本章相关的源代码存储库复制到新创建的目录中。此文件包含 XOR 实验的超参数完整配置,正如我们之前所讨论的。

本书将要讨论的实验使用各种实用工具来可视化结果,帮助我们理解神经进化过程的内部机制。XOR 实验还依赖于本书源代码存储库中visualize.py文件中实现的特定可视化实用工具。你需要将此文件复制到Chapter3目录中。

Anaconda 分发安装包括 VS Code,这是一个免费的跨*台代码编辑器。在功能方面相当直观,但提供了对 Python 的出色支持,并使得在虚拟环境之间切换变得容易。你可以用它来编写本书中描述的实验的源代码。

最后,在Chapter3目录中创建xor_experiment.py,并使用你喜欢的 Python 源代码编辑器编写代码:

  1. 首先,我们需要定义稍后将要使用的导入:
# The Python standard library import
import os
# The NEAT-Python library imports
import neat
# The helper used to visualize experiment results
import visualize
  1. 接下来,我们需要编写一些适应度评估代码,正如我们之前所描述的:
# The XOR inputs and expected corresponding outputs for 
# fitness evaluation
xor_inputs = [(0.0, 0.0), (0.0, 1.0), (1.0, 0.0), (1.0, 1.0)]
xor_outputs = [ (0.0,), (1.0,), (1.0,), (0.0,)]

def eval_fitness(net):
    """
    Evaluates fitness of the genome that was used to generate 
    provided net
    Arguments:
        net: The feed-forward neural network generated from genome
    Returns:
        The fitness score - the higher score the means 
        the better fit organism. Maximal score: 16.0
    """
    error_sum = 0.0
    for xi, xo in zip(xor_inputs, xor_outputs):
        output = net.activate(xi)
        error_sum += abs(xo[0] - output[0])
    # Calculate amplified fitness
    fitness = (4 - error_sum) ** 2
    return fitness

永远不要错过在源代码中添加注释的机会,描述函数的目的、输入参数和执行结果。对源代码中一些有趣/棘手的部分进行注释也有利,以便为将来看到它的人(这可能是你!)提供更好的理解。

  1. 使用适应度评估函数,你可以编写一个函数来评估当前代的所有生物体,并相应地更新每个基因组的适应度:
def eval_genomes(genomes, config):
    """
    The function to evaluate the fitness of each genome in 
    the genomes list. 
    The provided configuration is used to create feed-forward 
    neural network from each genome and after that created
    the neural network evaluated in its ability to solve
    XOR problem. As a result of this function execution, the
    fitness score of each genome updated to the newly
    evaluated one.
    Arguments:
        genomes: The list of genomes from population in the 
                current generation
        config: The configuration settings with algorithm
                hyper-parameters
    """
    for genome_id, genome in genomes:
        genome.fitness = 4.0
        net = neat.nn.FeedForwardNetwork.create(genome, config)
        genome.fitness = eval_fitness(net)
  1. 现在我们已经实现了评估单个基因组适应度的函数,并且目标函数已经被定义,是时候实现运行实验的函数了。run_experiment函数从配置文件中加载超参数配置并创建初始基因组种群:
    # Load configuration.
    config = neat.Config(neat.DefaultGenome, 
           neat.DefaultReproduction, neat.DefaultSpeciesSet, 
           neat.DefaultStagnation, config_file)

    # Create the population, which is the top-level object 
    # for a NEAT run.
    p = neat.Population(config)
  1. 我们对统计数据的积累感兴趣,以评估实验并实时观察过程。保存检查点也非常重要,这允许你在失败的情况下从给定的检查点恢复执行。因此,可以注册两种类型的报告器(标准输出和统计收集器)以及一个检查点收集器,如下所示:
    # Add a stdout reporter to show progress in the terminal.
    p.add_reporter(neat.StdOutReporter(True))
    stats = neat.StatisticsReporter()
    p.add_reporter(stats)
    p.add_reporter(neat.Checkpointer(5, 
                   filename_prefix='out/neat-checkpoint-'))
  1. 然后,我们准备通过提供eval_genome函数来运行300代的神经进化,该函数用于评估每一代种群中每个基因组的适应度分数,直到找到解决方案或过程达到最大代数:
    # Run for up to 300 generations.
    best_genome = p.run(eval_genomes, 300)
  1. 当 NEAT 算法的执行因成功或达到最大代数而停止时,将返回最健康的基因组。可以检查此基因组是否为赢家,即能否以给定的精度解决 XOR 问题:
    # Check if the best genome is an adequate XOR solver
    best_genome_fitness = eval_fitness(net)
    if best_genome_fitness > config.fitness_threshold:
        print("\n\nSUCCESS: The XOR problem solver found!!!")
    else:
        print("\n\nFAILURE: Failed to find XOR problem solver!!!")
  1. 最后,可以可视化收集到的统计信息和最佳匹配基因组,以探索神经进化过程的结果,并查看其从零到最大代数的表现:
    # Visualize the experiment results
    node_names = {-1:'A', -2: 'B', 0:'A XOR B'}
    visualize.draw_net(config, best_genome, True, 
       node_names=node_names, directory=out_dir)
    visualize.plot_stats(stats, ylog=False, view=True, 
       filename=os.path.join(out_dir, 'avg_fitness.svg'))
    visualize.plot_species(stats, view=True, 
       filename=os.path.join(out_dir, 'speciation.svg'))

XOR 实验运行器的完整源代码可以在 https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter3/xor_experiment.py 文件中找到。

由于前面的代码执行,Matplotlib 将用于渲染收集到的统计图。此外,还将展示最佳匹配基因组的网络图。

运行实验和分析结果

要开始实验,应在 Chapter3 目录中发出以下命令:

$ python xor_experiment.py

不要忘记使用 $ conda activate XOR_neat 激活 XOR_neat 虚拟环境。否则,将引发有关缺少 neat 包的错误。

在您选择的终端应用程序中输入前面的命令后,NEAT 算法开始执行,终端窗口开始实时显示中间结果。对于每一代,输出如下:

 ****** Running generation 43 ****** 

Population's average fitness: 6.01675 stdev: 2.53269
Best fitness: 14.54383 - size: (4, 7) - species 2 - id 5368
Average adjusted fitness: 0.238
Mean genetic distance 2.482, standard deviation 0.991
Population of 151 members in 5 species:
 ID age size fitness adj fit stag
 ==== === ==== ======= ======= ====
 1  43   28     9.0   0.241    0
 2  33   42    14.5   0.274    7
 3  20   39     9.0   0.306    0
 4   4   34     9.0   0.221    0
 5   1    8     8.4   0.149    0
Total extinctions: 0
Generation time: 0.045 sec (0.038 average)

在第 43 代,种群的*均健康分数(6.01675)与配置文件中设置的完成标准(fitness_threshold =15.5)相比相当低。然而,看起来我们有一些有潜力的冠军物种(ID: 2),它们正在通过进化具有健康分数 14.54383 的冠军生物体来达到目标健康分数阈值,该分数编码了一个由四个节点和七个连接组成的 ANN 表型(大小为 4,7)。

种群包括 151 个个体,分为五个物种,具有以下属性:

  • id 是物种标识符。

  • age 是物种的年龄,即从其创建到现在的代数。

  • size 是属于此物种的个体数量。

  • fitness 是从其个体(在我们的情况下为最大值)计算出的物种健康分数。

  • adj fit 是特定物种的适应性,它已被调整以适应整个种群的健康分数。

  • stag 是特定物种的停滞年龄,即自物种上次健康分数改善以来的代数。

当 NEAT 算法找到适当的 XOR 求解器时,终端窗口将显示以下输出。它以关于最终基因组种群和赢家(成功的 XOR 求解器)的一般统计数据开始:

 ****** Running generation 44 ****** 

Population's average fitness: 6.04705 stdev: 2.67702
Best fitness: 15.74620 - size: (3, 7) - species 2 - id 6531

Best individual in generation 44 meets fitness threshold - complexity: (3, 7)

从前面的输出中,我们可以看到,在代 44 中,进化过程创建了一个基因组,该基因组编码了一个表型 ANN,可以以给定的精度解决 XOR 问题。这个基因组属于 ID:2 物种的生物,而这个物种在过去七代中已经主导了进化过程。代 44 的冠军生物(ID:6531)是来自上一代 ID:2 物种的一个个体(ID:5368)的变异,它失去了一个隐藏节点,现在有三个节点和七个连接(大小:(3, 7))。

然后是最佳基因组部分:

Best genome:
Key: 6531
Fitness: 15.74619841601669
Nodes:
 0 DefaultNodeGene(key=0, bias=-3.175506745721987, response=1.0, activation=sigmoid, aggregation=sum)
 224 DefaultNodeGene(key=224, bias=-2.5796785460461154, response=1.0, activation=sigmoid, aggregation=sum)
 612 DefaultNodeGene(key=612, bias=-1.626648521448398, response=1.0, activation=sigmoid, aggregation=sum)
Connections:
 DefaultConnectionGene(key=(-2, 224), weight=1.9454770276940339, enabled=True)
 DefaultConnectionGene(key=(-2, 612), weight=2.1447044917213383, enabled=True)
 DefaultConnectionGene(key=(-1, 0), weight=-2.048078253002224, enabled=True)
 DefaultConnectionGene(key=(-1, 224), weight=3.6675667680178328, enabled=True)
 DefaultConnectionGene(key=(224, 0), weight=6.1133731818187655, enabled=True)
 DefaultConnectionGene(key=(612, 0), weight=-2.1334321035742474, enabled=True)
 DefaultConnectionGene(key=(612, 224), weight=1.5435290073038443, enabled=True)

最佳基因组部分代表了种群冠军的性能统计信息,以及其基因组配置。输入节点具有 IDs -1-2,并且没有显示,因为它们相对简单,为我们提供了将值输入到网络图中的手段。输出节点和两个隐藏节点分别具有 IDs 0224612。此外,DefaultNodeGene 包含了偏置值、激活函数的名称以及用于在每个节点聚合输入的函数的名称。稍后将要介绍的连接基因(DefaultConnectionGene)提供了源节点和目标节点的 ID,以及相关的连接权重。

最后,让我们看看 Output 部分:

Output:
input (0.0, 0.0), expected output (0.0,), got [1.268084297765355e-07]
input (0.0, 1.0), expected output (1.0,), got [0.9855287279878023]
input (1.0, 0.0), expected output (1.0,), got [0.9867962503269723]
input (1.0, 1.0), expected output (0.0,), got [0.004176868376596405]

Output 部分表示当接收四个输入数据对时,种群冠军表型的 ANN 生成的输出值。正如我们所见,输出值在指定的精度范围内接*预期值。

Output 目录还包含成功解决 XOR 问题的 ANN 图的图表,如下所示:

图片

XOR 胜者表型的 ANN

胜者表型的 ANN 接*我们之前描述的优化配置,但它有一个额外的隐藏节点(ID:612)。偏置节点在图中没有显示,因为 NEAT-Python 库不会为单独的节点分配偏置;相反,它将偏置值分配给每个网络节点作为属性,这可以在输出列表中看到(每个 DefaultNodeGene 都有一个偏置属性)。

一个包含进化过程中适应度变化统计的图表也被保存在 Output 目录中:

图片

种群*均和最佳适应度分数随代数的变化

前面的图表展示了种群在进化过程中的最佳和*均适应度分数的变化。种群的*均适应度略有提高。然而,由于在NEAT算法中引入的物种形成特性,一些物种从最早的一代(#10)就表现出卓越的性能,并且得益于对有益突变的保留,它们最终成功地产生了一个冠军生物体,该生物体以给定的精度解决了XOR问题。

Output目录还包含物种形成图,如下所示:

图片

进化过程中种群的多代物种形成

物种形成图展示了物种形成过程如何在生物种群的多代中演变。每个独立的物种都用不同的颜色标记。进化始于一个单一的物种(ID:1),它包括整个种群。然后,第二个物种(ID:2)在第10代左右出现,并最终产生了一个冠军生物体。此外,在进化的后期阶段,种群在代数233942时分支成了三个更多的物种。

练习

现在我们有了基于神经进化的XOR求解器的源代码,尝试通过改变控制进化过程的NEAT超参数进行实验。

其中一个特别感兴趣的参数是compatibility_threshold,它可以在配置文件的DefaultSpeciesSet部分找到:

  • 尝试增加其值并监控种群的物种形成。将算法的新值与默认值(3.0)进行比较,看是否有任何改进?

  • 如果你减小这个参数的值会发生什么?将其性能与默认值进行比较。

控制进化过程的另一个重要参数是min_species_size,它可以在DefaultReproduction部分找到。通过改变此参数的值,你可以直接控制每个物种的最小个体数,并隐式地控制物种的多样性:

  1. compatibility_threshold参数值设置为默认值(3.0),并尝试在范围 [2, 8] 内增加min_species_size参数的值。将算法的性能与默认值进行比较。查看物种多样性在代与代之间的变化。通过算法的输出检查是否有任何物种停滞并因超过停滞年龄而被从进化中移除。

  2. min_species_size参数值设置为极高(32)以适应我们的种群,并在物种形成图上寻找进化过程接*结束时物种多样性的爆炸。为什么会发生这种情况?检查表示ANN表型的配置的Digraph.gv.svg图。这是否是最优的?

增加物种的最小尺寸使进化过程更加精细,并允许它保留更多有益的突变。因此,我们增加了产生编码最小XOR求解器表型ANN的最优基因组的可能性。

最小XOR求解器的ANN图如下:

图片

增加最小物种尺寸的最优ANN表型

正如我们之前提到的,最小XOR求解器的ANN只有一个隐藏节点,如前图所示。

尝试实现一些修改后的代码来解决一个三XOR(A xor B xor C)问题。能否使用我们在本章描述的实验中使用的相同超参数来解决?

摘要

在本章中,我们介绍了一个与创建最优XOR求解器相关的经典计算机科学问题。我们讨论了XOR问题的基本原理,并展示了它在神经进化实验中的重要性——它允许你检查NEAT算法是否能够从最简单的ANN配置开始进化出更复杂的ANN拓扑结构。然后,我们定义了最优XOR求解器的目标函数和NEAT超参数的详细描述。之后,我们使用NEAT-Python库,通过定义的目标函数编写了XOR求解器的源代码,并进行了实验。

我们进行的实验结果使我们能够得出种群中物种数量、每个物种的最小尺寸以及算法性能之间的关系,以及产生的ANN拓扑结构。

在下一章中,我们将学习经典的强化学习实验,这些实验通常用作控制策略实现的基准。你将学习如何编写真实物理装置的准确模拟,以及如何使用这些模拟来定义NEAT算法的目标函数。你将亲身体验使用NEAT-Python库编写各种小车*衡控制器的控制策略。

第六章:杆*衡实验

在本章中,你将了解一个经典的强化学习实验,它也是测试各种控制策略实现的既定基准。在本章中,我们考虑了小车-杆*衡实验的三个修改版本,并开发了可用于稳定给定配置的小车-杆装置的控制策略。你将学习如何编写真实物理系统的准确模拟,以及如何使用它们为NEAT算法定义目标函数。在本章之后,你将准备好应用NEAT算法来实现可以直接用于控制物理设备的控制器。

在本章中,我们将涵盖以下主题:

  • 强化学习中的单杆*衡问题

  • Python中实现小车-杆装置模拟器的实现

  • 如何使用模拟器定义单杆*衡控制器的目标函数

  • 双杆*衡问题的特殊性

  • Python中实现具有两杆的小车-杆装置模拟器的实现

  • 如何为双杆*衡控制器定义目标函数

技术要求

为了执行本章中描述的实验,应满足以下技术要求:

  • Windows 8/10,macOS 10.13或更新的版本,现代Linux

  • Anaconda Distribution版本2019.03或更新的版本

本章的代码可以在https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/tree/master/Chapter4找到

单杆*衡问题

单杆*衡器(或倒立摆)是一个不稳定的摆,其质心位于其支点之上。通过应用外部力,在监控杆角度并使支点在质心下方左右移动以防止摆动开始下落时,可以将其稳定。单杆*衡器是动力学和控制理论中的一个经典问题,被用作测试控制策略的基准,包括基于强化学习方法的策略。我们特别感兴趣的是实现使用基于神经进化的方法稳定倒立摆的特定控制算法。

本章中描述的实验考虑了作为可以水*移动的带有顶部安装支点的车的倒立摆的模拟。该装置在以下图中显示:

图片

小车和单杆装置

在我们开始编写模拟器的源代码之前,我们需要确定可以用来估计*衡杆在任何给定时间的状态变量值的运动方程。

单*衡杆运动方程

控制器的目标是施加一系列力,,在小车的质心处,使得*衡杆在特定(或无限)时间内保持*衡,并且小车保持在轨道内,即不撞击左右墙壁。考虑到这里描述的力学,我们可以将*衡杆任务定性为避障控制问题,因为必须保持小车-*衡杆装置的状态以避免状态空间中的某些区域。对于适当的状态估计不存在唯一解,任何能够避免某些区域的运动方程的解都是可接受的。

学习算法需要从环境中接收关于任务的最小知识量来训练*衡杆控制器。这种知识应该反映我们的控制器离目标有多*。*衡杆问题的目标是稳定一个本质上不稳定的系统,并尽可能长时间地保持*衡。因此,从环境中接收到的强化信号 () 必须反映失败的发生。失败可能是由于*衡杆超过预定义的角度或小车撞击轨道边界造成的。强化信号,,可以定义为以下内容:

在这个方程中, 是*衡杆与垂直正方向顺时针方向的夹角,而 是小车相对于轨道的水*位置。

注意,强化信号,,既不依赖于角*衡杆速度 (),也不依赖于水*小车速度 ()。它只提供关于小车-*衡杆系统的动力学是否在定义的约束之内信息。

忽略摩擦的小车-*衡杆系统的运动动力学方程如下:

在这个方程中,  是杆的角速度,而 是杆的角加速度。此外,  是购物车的水*速度,而 是购物车沿 -轴的加速度。

在我们的实验中,使用了以下系统参数:

  • 是购物车的质量。

  • 是杆的质量。

  • 是杆质心到支点的距离。

  • 是重力加速度。

状态方程和控制动作

实验中使用的购物车-杆系统是通过使用步长为  秒的欧拉方法数值*似运动方程来模拟的。因此,状态方程可以定义为如下:

对于实验中使用的极小范围的杆角度,我们可以使用分割系统所有可能状态空间(切换表面)的表面的线性*似。因此,动作空间由左右推动作组成。我们在实验中使用的购物车-杆控制器不是为了产生零力。相反,在每一个时间步, ,它对购物车质心施加一个等幅的力,但方向相反。这种控制系统有一个名字(bang-bang 控制器)并且可以用以下方程定义:

在这个方程中,  是从求解器接收到的动作信号。给定动作值后,bang-bang 控制器应用相同大小(10 牛顿)但方向相反的力, ,取决于所选的动作。

求解器和模拟器之间的交互

求解器在每个给定的时间点接收之前描述的状态变量的缩放值, 。这些值作为从求解器基因组表型创建的 ANNs 的输入,并定义为如下:

,

,

,

,

.

在第一个方程中, 是一个常量偏置值,而 分别对应于购物车的水*位置、其水*速度、从垂直方向测量的极角以及其角速度。

考虑到之前定义的系统约束(参见 ), 的缩放值保证在 [0,1] 范围内,而 的缩放值大多落在 [0,1] 范围内,但最终可能超出这些界限。状态变量被缩放以实现两个基本目标:

  • 为了消除当具有主要大值的项由于舍入效应而对学习者产生更大影响时可能出现的学习偏差。

  • 对于这个特定任务,由于状态变量的值围绕零中心,可以找到一个不需要任何隐藏单元的ANN求解器。然而,我们感兴趣的是使用NEAT算法进化神经网络的拓扑结构。引入的缩放方案确保神经进化过程最终产生编码隐藏单元的表型。

极*衡控制器接收缩放后的输入并产生一个输出,该输出是一个二进制值,用于确定在之前讨论的时间 t 应该应用的动作。购物车-杆系统的状态变量采样率以及施加控制力的速率与仿真速率相同,

因此,控制器ANN的初始配置可以描述如下:

单极*衡控制器ANN的初始配置

单极*衡控制器ANN的初始配置包括五个输入节点,用于购物车的水*位置(x1)及其速度(x2),杆的垂直角度(x3)及其角速度(x4),以及一个额外的偏置输入节点(x0)(这可以根据特定NEAT库的使用情况是可选的)。输出节点(a)是一个二进制节点,提供控制信号到[01]应用。隐藏节点(h)是可选的,可以跳过。

单极*衡实验的目标函数

我们的目标是创建一个能够尽可能长时间(但至少为实验配置中指定的预期时间步数,即500,000步)在定义的约束内保持系统稳定状态的极*衡控制器。因此,目标函数必须优化稳定极*衡的持续时间,可以定义为预期步数与在评估表现型ANN期间获得的实际步数之间的对数差。损失函数如下所示:

在这个实验中,是实验配置中的预期时间步数,而是控制器能够在允许的范围内保持稳定极*衡状态的实际时间步数(有关允许范围的详细信息,请参阅强化信号定义)。

车杆装置模拟

目标函数的定义假设我们可以测量单极*衡器处于稳定状态的时间步数。为了进行此类测量,我们需要实现车杆装置的模拟器,使用之前定义的运动方程和数据约束。

本章的源代码可在https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/tree/master/Chapter4找到。

首先,我们需要在work目录中创建一个名为cart_pole.py的文件。此文件包含运动方程和评估单极*衡器适应度的函数的源代码:

  1. 我们从定义描述车杆装置物理学的常量开始:
 GRAVITY = 9.8 # m/s^2
 MASSCART = 1.0 # kg
 MASSPOLE = 0.5 # kg
 TOTAL_MASS = (MASSPOLE + MASSCART)
 # The distance from the center of mass of the pole to the pivot
 # (actually half the pole's length)
 LENGTH = 0.5 # m
 POLEMASS_LENGTH = (MASSPOLE * LENGTH) # kg * m
 FORCE_MAG = 10.0 # N
 FOURTHIRDS = 4.0/3.0
 # the number seconds between state updates 
 TAU = 0.02 # sec
  1. 然后,我们准备使用这些常量实现运动方程:
    force = -FORCE_MAG if action <= 0 else FORCE_MAG
    cos_theta = math.cos(theta)
    sin_theta = math.sin(theta)
    temp = (force + POLEMASS_LENGTH * theta_dot * theta_dot * \
           sin_theta) / TOTAL_MASS
    # The angular acceleration of the pole
    theta_acc = (GRAVITY * sin_theta - cos_theta * temp) /\ 
                  (LENGTH * (FOURTHIRDS - MASSPOLE * \
                   cos_theta * cos_theta / TOTAL_MASS))
    # The linear acceleration of the cart
    x_acc = temp - POLEMASS_LENGTH * theta_acc * \
            cos_theta / TOTAL_MASS
    # Update the four state variables, using Euler's method.
    x_ret = x + TAU * x_dot
    x_dot_ret = x_dot + TAU * x_acc
    theta_ret = theta + TAU * theta_dot
    theta_dot_ret = theta_dot + TAU * theta_acc

有关此章节源代码中do_step(action, x, x_dot, theta, theta_dot)函数实现的详细信息,请参阅下一节。

上述代码片段使用当前系统状态(x, x_dot, theta, theta_dot)以及一个控制动作作为输入,并应用之前描述的运动方程来更新下一个时间步的系统状态。然后,更新后的系统状态返回以更新模拟器并检查约束违规。因此,模拟周期组织如下一节所述。

模拟周期

现在我们已经完全实现了小车-杆装置模拟一步的动力学方程和状态变量的数值*似。有了这个,我们就可以开始实现完整模拟周期了,该周期使用控制器的ANN来评估当前系统状态并选择适当的动作(下一步要施加的力)。前面提到的ANN是为特定进化代中的每个基因组的基因组创建的,使我们能够评估所有基因组的性能。

请参阅run_cart_pole_simulation(net, max_bal_steps, random_start=True)函数的实现以获取完整的实现细节。

我们可以参考以下步骤来执行完整模拟周期的实现:

  1. 首先,我们需要初始化初始状态变量,要么用零,要么用之前描述的约束范围内的随机值,并围绕零进行初始化。随机状态值可以创建如下:
    # -1.4 < x < 1.4
    x = (random.random() * 4.8 - 2.4) / 2.0
    # -0.375 < x_dot < 0.375
    x_dot = (random.random() * 3 - 1.5) / 4.0 
    # -0.105 < theta < 0.105
    theta = (random.random() * 0.42 - 0.21) / 2.0
    # -0.5 < theta_dot < 0.5
    theta_dot = (random.random() * 4 - 2) / 4.0

我们故意将所有值与相应的缩放约束范围相比进行了缩减,以确保算法不会从临界状态开始,即当稳定化不再可能时。

  1. 之后,我们准备开始模拟周期,该周期由max_bal_steps参数指定的步数定义。以下代码是在模拟循环中执行的。

  2. 在将状态变量作为输入加载到控制器的ANN之前,需要将其缩放到[0,1]范围内。这个程序具有计算和进化的优势,如前所述。偏差值没有明确提供,因为NEAT-Python框架内部处理它,所以可以在源代码中定义ANN的输入如下:

    input[0] = (x + 2.4) / 4.8
    input[1] = (x_dot + 1.5) / 3
    input[2] = (theta + 0.21) / .42
    input[3] = (theta_dot + 2.0) / 4.0
  1. 接下来,可以缩放输入来激活表型的ANN,并使用其输出产生动作的离散值:
    # Activate the NET
    output = net.activate(input)
    # Make action values discrete
    action = 0 if output[0] < 0.5 else 1
  1. 使用产生的动作值和当前的状态变量值,可以运行小车-杆模拟的单步。在模拟步骤之后,返回的状态变量将测试是否在约束范围内,以检查系统状态是否仍然在边界内。

在失败的情况下,返回当前的模拟步数,其值将用于评估表型的适应度:

    # Apply action to the simulated cart-pole
    x, x_dot, theta, theta_dot = do_step(action = action, 
                      x = x, x_dot = x_dot, 
                      theta = theta, theta_dot = theta_dot )

    # Check for failure due constraints violation. 
    # If so, return number of steps.
    if x < -2.4 or x > 2.4 or theta < -0.21 or theta > 0.21:
        return steps

如果控制器的ANN能够在整个模拟步骤中维持小车-杆装置*衡的稳定状态,则run_cart_pole_simulation函数将返回具有最大模拟步数的值。

基因组适应度评估

使用前面描述的run_cart_pole_simulation函数返回的成功模拟步数,我们准备实现基因组适应度评估函数:

  1. 首先,我们运行小车-杆模拟循环,它返回成功的模拟步数:
    steps = run_cart_pole_simulation(net, max_bal_steps)
  1. 之后,我们准备评估特定基因组的适应性分数,如前所述:
    log_steps = math.log(steps)
    log_max_steps = math.log(max_bal_steps)
    # The loss value is in range [0, 1]
    error = (log_max_steps - log_steps) / log_max_steps
    # The fitness value is a complement of the loss value
    fitness = 1.0 - error

请参阅eval_fitness(net, max_bal_steps=500000)函数以获取更多详细信息。

我们使用对数刻度,因为大多数模拟运行在约100步时失败,但我们测试了500000*衡步。

单极*衡实验

现在我们已经定义并实现了目标函数,以及与滑车杆装置动力学模拟,我们准备开始编写源代码以使用NEAT算法运行神经进化过程。我们将使用与上一章中XOR实验相同的NEAT-Python库,但适当调整NEAT超参数。这些超参数存储在single_pole_config.ini文件中,该文件可在与本章相关的源代码存储库中找到。您需要将此文件复制到您的本地Chapter4目录中,在该目录中您应该已经有一个包含我们之前创建的滑车杆模拟器的Python脚本。

超参数选择

在配置文件的NEAT部分,我们定义了生物种群数量为个个体,以及适应性阈值1.0作为终止标准。

fitness_criterion设置为max,这意味着当任何个体达到等于fitness_threshold值的适应性分数时,进化过程终止:

[NEAT]
fitness_criterion   = max
fitness_threshold   = 1.0
pop_size            = 150
reset_on_extinction = False

此外,我们还显著降低了添加新节点的概率,以使进化过程偏向在控制器中使用最小数量的ANN节点来详细阐述连接模式。因此,我们的目标是减少进化控制器ANN的能量消耗和减少训练的计算成本。

配置文件中相应的参数如下:

# node add/remove rates
node_add_prob    = 0.02
node_delete_prob = 0.02

描述我们初始网络配置的参数,通过隐藏、输入和输出节点的数量给出如下:

# network parameters
num_hidden = 0
num_inputs = 4
num_outputs = 1

我们提高了物种的兼容性阈值,以使进化过程偏向产生更少的物种。同时,我们增加了最小物种大小,表示我们对具有更大机会保留有益突变的、高度密集的物种感兴趣。在此同时,我们降低了最大停滞年龄,通过提高停滞物种早期灭绝来加强进化过程,这些停滞物种没有显示出任何适应性改进。

配置文件中的相关参数如下:

[DefaultSpeciesSet]
compatibility_threshold = 4.0

[DefaultStagnation]
species_fitness_func = max
max_stagnation = 15
species_elitism = 2

[DefaultReproduction]
elitism = 2
survival_threshold = 0.2
min_species_size = 8

请参阅single_pole_config.ini配置文件以获取完整详情。

由于配置参数的影响,在进化过程中将使用更多种类的生物;然而,独特种类的数量将保持较低。

工作环境设置

在您开始编写实验运行器的源代码之前,您必须设置一个虚拟Python环境并安装所有必要的依赖项。您可以通过在命令行中执行以下命令使用Anaconda来完成此操作:

$ conda create --name single_pole_neat python=3.5
$ conda activate single_pole_neat
$ pip install neat-python==0.92 
$ conda install matplotlib
$ conda install graphviz
$ conda install python-graphviz

首先,这些命令创建并激活一个Python 3.5的single_pole_neat虚拟环境。之后,安装了NEAT-Python库版本0.92,以及我们的可视化工具使用的其他依赖项。

实验运行器实现

首先,您需要在Chapter4目录下创建一个single_pole_experiment.py文件。在该文件中,将编写单极*衡实验的源代码。此外,您还需要将章节仓库中的visualize.py文件复制到该目录中。我们将使用该文件中的可视化工具来渲染实验结果。

实验运行器脚本包括两个基本函数。

评估种群中所有基因组适应度的函数

第一个函数评估种群中所有基因组的列表,并为每个基因组分配一个适应度分数。此函数通过引用传递给NEAT-Python库的神经进化运行器。此函数的源代码如下:

def eval_genomes(genomes, config):
    for genome_id, genome in genomes:
        genome.fitness = 0.0
        net = neat.nn.FeedForwardNetwork.create(genome, config)
        fitness = cart.eval_fitness(net)
        if fitness >= config.fitness_threshold:
            # do additional steps of evaluation with random initial states
            # to make sure that we found stable control strategy rather 
            # than special case for particular initial state
            success_runs = evaluate_best_net(net, config, 
                                             additional_num_runs)
            # adjust fitness
            fitness = 1.0 - (additional_num_runs - success_runs) / \
                      additional_num_runs

        genome.fitness = fitness

注意,我们为获胜的基因组引入了额外的模拟运行,以确保其控制策略在从各种随机初始状态开始时是稳定的。这个额外的检查确保我们找到了真正的获胜者,而不是特定于特定初始状态的特殊情况。

前一个函数接收种群中所有基因组的列表和NEAT配置参数。对于每个特定的基因组,它创建表型ANN并将其用作控制器来运行定义在以下代码片段中的cart-pole装置模拟:

fitness = cart.eval_fitness(net)

返回的适应度分数然后与我们在配置参数中定义的适应度阈值值进行比较。如果它超过了阈值,我们可以假设找到了一个成功的控制器。为了进一步验证找到的控制器,它将在额外的模拟运行中进行测试,并计算最终的适应度分数(如以下代码片段所示):

success_runs = evaluate_best_net(net, config, additional_num_runs)
fitness = 1.0 - (additional_num_runs - success_runs) / additional_num_runs

额外的模拟步骤将使用不同的随机数生成器种子来覆盖cart-pole装置的大多数可能的初始配置。

实验运行器函数

第二个函数配置、执行并输出神经进化过程的结果。在此,我们概述了实验运行器函数实现中的几个关键位置:

  1. 函数开始于从配置文件中加载超参数并使用加载的配置生成初始种群:
    # Load configuration.
    config = neat.Config(neat.DefaultGenome, 
                         neat.DefaultReproduction,
                         neat.DefaultSpeciesSet, 
                         neat.DefaultStagnation,
                         config_file)

    # Create the population, which is the top-level object 
    # for a NEAT run.
    p = neat.Population(config)
  1. 之后,它配置了统计报告器以收集有关进化过程执行的统计数据。同时添加了输出报告器,以便实时将执行结果输出到控制台。还配置了检查点收集器以保存执行的中途阶段,这在需要稍后恢复训练过程时可能很有用:
    # Add a stdout reporter to show progress in the terminal.
    p.add_reporter(neat.StdOutReporter(True))
    stats = neat.StatisticsReporter()
    p.add_reporter(stats)
    p.add_reporter(neat.Checkpointer(5, 
                 filename_prefix=‘out/spb-neat—checkpoint-'))
  1. 最后,在指定的代数数上执行进化过程,并将结果保存在output目录中:
    # Run for up to N generations.
    best_genome = p.run(eval_genomes, n=n_generations)

    # Display the best genome among generations.
    print('\nBest genome:\n{!s}'.format(best_genome))

    # Check if the best genome is a winning Single-Pole 
    # balancing controller 
    net = neat.nn.FeedForwardNetwork.create(best_genome, config)
    best_genome_fitness = cart.eval_fitness(net)
    if best_genome_fitness >= config.fitness_threshold:
        print("\n\nSUCCESS: The Single-Pole balancing controller has been found!!!")
    else:
        print("\n\nFAILURE: Failed to find Single-Pole balancing controller!!!")

请参阅run_experiment(config_file, n_generations=100)函数以获取完整的实现细节。

在进化过程中找到最佳基因组后,它被验证是否实际上符合我们在配置文件中设置的适应度阈值标准。在过程中可能找不到有效解决方案,但无论如何,NEAT-Python库将返回最佳匹配的基因组。这就是为什么我们需要这个额外的检查来确保最终的最佳匹配基因组实际上可以解决实际问题。

运行单极*衡实验

您需要进入包含single_pole_experiment.py文件的目录,并执行以下命令:

$ python single_pole_experiment.py

不要忘记使用以下命令激活适当的虚拟环境:

conda activate single_pole_neat

在执行Python脚本的过程中,控制台将为每一代的进化打印以下输出:

 ****** Running generation 13 ****** 

Population's average fitness: 0.26673 stdev: 0.12027
Best fitness: 0.70923 - size: (1, 2) - species 1 - id 2003
Average adjusted fitness: 0.161
Mean genetic distance 1.233, standard deviation 0.518
Population of 150 members in 1 species:
 ID age size fitness adj fit stag
 ==== === ==== ======= ======= ====
 1 13 150 0.7 0.161 7
Total extinctions: 0
Generation time: 4.635 sec (0.589 average)

在输出中,你可以看到在生成14时,种群的*均适应度较低,但表现最佳生物体的适应度(0.70923)已经接*我们在配置文件中设置的完成阈值值(fitness_threshold = 1.0)。冠军生物体编码的表型ANN由一个非线性节点(输出)和仅两个连接(size: (1, 2))组成。此外,值得注意的是,种群中只存在一个物种。

在找到获胜者后,控制台输出以下行:

 ****** Running generation 14 ****** 

Population's average fitness: 0.26776 stdev: 0.13359
Best fitness: 1.00000 - size: (1, 3) - species 1 - id 2110

Best individual in generation 14 meets fitness threshold - complexity: (1, 3)

Best genome:
Key: 2110
Fitness: 1.0
Nodes:
 0 DefaultNodeGene(key=0, bias=-3.328545880116371, response=1.0, activation=sigmoid, aggregation=sum)
Connections:
 DefaultConnectionGene(key=(-4, 0), weight=2.7587300138861037, enabled=True)
 DefaultConnectionGene(key=(-3, 0), weight=2.951449584136504, enabled=True)
 DefaultConnectionGene(key=(-1, 0), weight=0.9448711043565166, enabled=True)

Evaluating the best genome in random runs
Runs successful/expected: 100/100
SUCCESS: The stable Single-Pole balancing controller has been found!!!

作为进化获胜者的最佳基因组编码了一个仅由一个非线性节点(输出)和从输入节点(size: (1, 3))来的三个连接组成的表型ANN。值得注意的是,进化能够产生一个稳固的控制策略,完全忽略了滑车的线性速度,只使用了其他三个输入:xθθ。这一事实是进化选择正确性的另一个标志,因为我们决定忽略滑车的摩擦,这实际上排除了滑车线性速度在运动方程中的作用。

带有ANN的单极*衡控制器获胜图的展示如下:

由NEAT算法找到的单极*衡控制器的ANN

进化过程中适应度值随代数变化的图表如下:

单极实验中种群的*均和最佳适应度

所有代中种群的*均适应度都很低,但从一开始就有一个有益的突变产生了特定的生物种群谱系。从一代到下一代,该谱系中的有才能的个体不仅能够保留其有益的特征,而且还能改进它们,这最终导致了进化获胜者的出现。

练习

  1. 尝试增加node_add_prob参数的值,看看会发生什么。算法是否会产生一定数量的隐藏节点,如果是的话,有多少个?

  2. 尝试降低/增加compatibility_threshold的值。如果你将其设置为2.06.0会发生什么?算法是否能在每种情况下找到解决方案?

  3. 尝试在DefaultReproduction部分将elitism值设置为零。看看会发生什么。在这种情况下,进化过程找到可接受的解决方案需要多长时间?

  4. DefaultReproduction部分将survival_threshold值设置为0.5。看看这如何影响进化过程中的物种形成。为什么会有这样的影响?

  5. additional_num_runsadditional_steps的值按量级增加,以进一步检验找到的控制策略的泛化能力。算法是否仍然能够找到获胜的解决方案?

最后一个练习将导致算法执行时间的增加。

双极*衡问题

单极*衡问题对于NEAT算法来说足够简单,它可以快速找到保持稳定系统状态的最佳控制策略。为了使实验更具挑战性,我们提出了一个更高级的滑车-杆*衡问题。在这个版本中,两个杆通过一个铰链连接到移动的滑车上。

新的滑车-杆装置的方案如下:

图片

具有两个杆的滑车-杆装置

在我们转向实验的具体实现细节之前,我们需要定义双极*衡系统模拟的状态变量和运动方程。

系统状态和运动方程

控制器的目标是施加力到滑车上,以尽可能长时间地保持两个杆的*衡。同时,滑车应保持在定义的边界内。与之前讨论的单极*衡问题一样,控制策略可以定义为避免控制问题,这意味着控制器必须保持系统状态稳定,避免当滑车移动到轨道边界之外或任一杆超出允许的垂直角度时的危险区域。这个问题没有唯一解,但由于杆的长度和质量不同,因此它们对控制输入的反应也不同。

双极*衡装置的当前状态可以通过以下变量定义:

  • 滑车在轨道上的位置 ()

  • 滑车速度 ()

  • 第一柱与垂直线的角度 ()

  • 第一柱的角速度 ()

  • 第二柱与垂直线的角度 ()

  • 第二柱的角速度 ()

忽略滑车轮与轨道之间摩擦的、*衡在单个滑车上的两个未连接柱子的运动方程如下:

在此方程中, 是柱子 对滑车的反作用力:

在此方程中, 是柱子 的有效质量:

以下参数用于双柱模拟:

符号 描述
轨道上滑车的位置
柱子与垂直线的角度
对滑车施加的控制力
柱子质心到支点的距离
滑车的质量
柱子的质量
对滑车施加的控制力
由于重力引起的自由落体加速度

对应的Python代码将这些系统参数定义为常量:

GRAVITY = -9.8 # m/s^2 - here negative as equations of motion for 2-pole system assume it to be negative
MASS_CART = 1.0 # kg
FORCE_MAG = 10.0 # N
# The first pole
MASS_POLE_1 = 1.0 # kg
LENGTH_1 = 0.5 # m - actually half the first pole's length
# The second pole
MASS_POLE_2 = 0.1 # kg
LENGTH_2 = 0.05 # m - actually half the second pole's length
# The coefficient of friction of pivot of the pole
MUP = 0.000002

在Python中实现运动方程如下:

# Find the input force direction
force = (action - 0.5) * FORCE_MAG * 2.0 # action has binary values
# Calculate projections of forces for the poles
cos_theta_1 = math.cos(theta1)
sin_theta_1 = math.sin(theta1)
g_sin_theta_1 = GRAVITY * sin_theta_1
cos_theta_2 = math.cos(theta2)
sin_theta_2 = math.sin(theta2)
g_sin_theta_2 = GRAVITY * sin_theta_2
# Calculate intermediate values
ml_1 = LENGTH_1 * MASS_POLE_1
ml_2 = LENGTH_2 * MASS_POLE_2
temp_1 = MUP * theta1_dot / ml_1
temp_2 = MUP * theta2_dot / ml_2
fi_1 = (ml_1 * theta1_dot * theta1_dot * sin_theta_1) + \
       (0.75 * MASS_POLE_1 * cos_theta_1 * (temp_1 + g_sin_theta_1))
fi_2 = (ml_2 * theta2_dot * theta2_dot * sin_theta_2) + \
       (0.75 * MASS_POLE_2 * cos_theta_2 * (temp_2 + g_sin_theta_2))
mi_1 = MASS_POLE_1 * (1 - (0.75 * cos_theta_1 * cos_theta_1))
mi_2 = MASS_POLE_2 * (1 - (0.75 * cos_theta_2 * cos_theta_2))
# Calculate the results: cart acceleration and poles angular accelerations
x_ddot = (force + fi_1 + fi_2) / (mi_1 + mi_2 + MASS_CART)
theta_1_ddot = -0.75 * (x_ddot * cos_theta_1 + \
                        g_sin_theta_1 + temp_1) / LENGTH_1
theta_2_ddot = -0.75 * (x_ddot * cos_theta_2 + \
                        g_sin_theta_2 + temp_2) / LENGTH_2

更多实现细节可在与Chapter4源代码文件相关的存储库中的cart_two_pole.py文件中找到。请参阅calc_step(action, x, x_dot, theta1, theta1_dot, theta2, theta2_dot)函数。

前面的代码接收当前系统状态(x, x_dot, theta1, theta1_dot, theta2, theta2_dot)以及控制动作,并计算导数(小车加速度和两个极的角加速度)。

强化信号

模拟环境必须在执行动作后以强化信号()的形式提供关于系统状态的最小信息。强化信号指示在应用动作后,双极*衡系统是否违反了边界约束。它可以定义为以下:

Python中强化信号生成的实现如下:

res = x < -2.4 or x > 2.4 or \
    theta1 < -THIRTY_SIX_DEG_IN_RAD or theta1 > THIRTY_SIX_DEG_IN_RAD or \
    theta2 < -THIRTY_SIX_DEG_IN_RAD or theta2 > THIRTY_SIX_DEG_IN_RAD

条件检查每个极的角度是否为  相对于垂直方向,以及小车位置是否为  相对于轨道中心。

初始条件和状态更新

在单极*衡实验中,我们使用了随机的初始状态条件,但有两个极时,初始条件更为简化。系统开始时,所有小车和极的速度都设置为零。长极的初始位置与垂直方向相差一度,短极完全直立。

初始条件如下:

通过使用时间步长为0.01秒的Runge-Kutta四阶方法对运动方程进行数值*似,在每个模拟步骤中更新小车-极系统的状态。Runge-Kutta四阶*似方法允许根据当前时间步的状态变量计算系统响应。新的控制输入每秒生成一次。因此,控制频率为50 Hz,系统状态更新频率为100 Hz

Python中Runge-Kutta四阶方法实现如下:

  1. 使用当前小车-极装置状态变量更新下一个半时间步的中间状态,并执行第一次模拟步骤:
hh = tau / 2.0
yt = [None] * 6

# update intermediate state
for i in range(6):
    yt[i] = y[i] + hh * dydx[i]

# do simulation step
x_ddot, theta_1_ddot, theta_2_ddot = calc_step(action = f, yt[0], yt[1], yt[2], yt[3], yt[4], yt[5])

# store derivatives
dyt = [yt[1], x_ddot, yt[3], theta_1_ddot, yt[5], theta_2_ddot]
  1. 使用第一次模拟步骤获得的导数更新中间状态,并执行第二次模拟步骤:
# update intermediate state 
for i in range(6):
    yt[i] = y[i] + hh * dyt[i]

# do one simulation step
x_ddot, theta_1_ddot, theta_2_ddot = calc_step(action = f, yt[0], yt[1], yt[2], yt[3], yt[4], yt[5])

# store derivatives
dym = [yt[1], x_ddot, yt[3], theta_1_ddot, yt[5], theta_2_ddot]
  1. 使用第一次和第二次模拟步骤的导数更新中间状态,并使用更新后的状态执行第三次极*衡模拟步骤:
# update intermediate state
for i in range(6):
    yt[i] = y[i] + tau * dym[i]
    dym[i] += dyt[i]

# do one simulation step
x_ddot, theta_1_ddot, theta_2_ddot = calc_step(action = f, yt[0], yt[1], yt[2], yt[3], yt[4], yt[5])

# store derivatives
dyt = [yt[1], x_ddot, yt[3], theta_1_ddot, yt[5], theta_2_ddot]
  1. 最后,使用前三个模拟步骤的导数来*似用于进一步模拟的小车-杆装置的最终状态:
# find system state after approximation
yout = [None] * 6 # the approximated system state
h6 = tau / 6.0
for i in range(6):
    yout[i] = y[i] + h6 * (dydx[i] + dyt[i] + 2.0 * dym[i])

让我们检查前面方程的元素:

  • f是在模拟期间应用的控制动作(01)。

  • y是一个包含状态变量当前值()的列表。

  • dydx是一个包含状态变量导数()的列表。

  • tau是*似的时间步长大小。

对于更多实现细节,请参阅cart_two_pole.py文件中的rk4(f, y, dydx, tau)函数。

这种四阶龙格-库塔方法的实现接收当前系统状态(x, x_dot, theta1, theta1_dot, theta2, theta2_dot)以及导数,并*似下一个时间步的系统状态。

控制动作

与本章前面讨论的单极*衡实验一样,双极*衡实验的控制系统只生成两个控制信号:向左推和向右推,力是恒定的。因此,时间的控制力可以定义为以下:

图片

在前面的方程中,是控制器在时间接收到的动作信号。

解算器和模拟器之间的交互

状态变量在作为控制器ANN的输入应用之前被缩放到[0,1]范围内。因此,状态输入变量预处理的方程如下:

图片

图片

图片

图片

图片

图片

在前面的方程中,对应于小车在水*方向的位置,其水*速度,第一个极点相对于垂直的角度,其角速度,以及第二个极点的角度和角速度,分别。

考虑到之前定义的系统约束(见),的缩放值保证在[0,1]范围内,而的缩放值大多落在0...1范围内,但最终可能会超出这些界限。

输入缩放的相应源代码如下:

input[0] = (state[0] + 2.4) / 4.8
input[1] = (state[1] + 1.5) / 3.0
input[2] = (state[2] + THIRTY_SIX_DEG_IN_RAD) / (THIRTY_SIX_DEG_IN_RAD * 2.0)
input[3] = (state[3] + 2.0) / 4.0
input[4] = (state[4] + THIRTY_SIX_DEG_IN_RAD) / (THIRTY_SIX_DEG_IN_RAD * 2.0)
input[5] = (state[5] + 2.0) / 4.0

状态列表按照以下顺序保存当前状态变量: .

双摆*衡实验的目标函数

这个问题的目标函数与之前为单摆*衡问题定义的目标函数相似。它由以下方程给出:

在这些方程中,  是实验配置中指定的预期时间步数(100,000),而  是控制器能够维持摆杆*衡器在指定范围内稳定状态的实际时间步数。

我们使用对数刻度,因为大多数试验在前几步(100步左右)就失败了,但我们是在测试100,000步。在对数刻度下,我们有一个更好的适应度分数分布,即使与失败试验中的少量步骤相比。

前述方程的第一个定义了损失,其范围在 [0,1] 之间,第二个是一个补充损失值的适应度分数。因此,适应度分数值也在 [0,1] 范围内,值越高,结果越好。

Python源代码与单摆*衡实验中的目标函数定义相似,但它使用不同的模拟器调用以获取*衡步数:

# First we run simulation loop returning number of successful
# simulation steps
steps = cart.run_markov_simulation(net, max_bal_steps)

if steps == max_bal_steps:
    # the maximal fitness
    return 1.0
elif steps == 0: # needed to avoid math error when taking log(0)
    # the minimal fitness
    return 0.0
else:
    log_steps = math.log(steps)
    log_max_steps = math.log(max_bal_steps)
    # The loss value is in range [0, 1]
    error = (log_max_steps - log_steps) / log_max_steps
    # The fitness value is a complement of the loss value
    return 1.0 - error

我们在这里使用对数刻度,因为大多数运行在100步左右就失败了,但我们是在测试100,000步。

双摆*衡实验

这个实验使用的是双摆*衡问题的版本,它假设对当前系统状态有完全的了解,包括极的角速度和车的速度。这个实验成功的标准是保持两个极在100,000步内*衡,或者大约33分钟的模拟时间。当极保持在垂直方向的  度以内时,我们认为它是*衡的,而车保持在轨道中心的  米以内。

超参数选择

与本章中描述的先前实验相比,双摆*衡由于其复杂的运动动力学而更难解决。因此,成功控制策略的搜索空间更广,需要更多样化的种群。为了增加种群的多样性,我们将种群大小增加到单摆*衡实验的10倍。

适应度终止阈值与这里显示的相同:

[NEAT]
fitness_criterion = max
fitness_threshold = 1.0
pop_size = 1000
reset_on_extinction = False

为了进一步增强进化多样性,我们增加了添加新节点和连接的概率,以及改变初始连接配置方案的概率。此外,initial_connection参数的值包含了连接创建的概率,这为连接图的生产过程引入了额外的非确定性:

# connection add/remove rates
conn_add_prob = 0.5
conn_delete_prob = 0.2

initial_connection = partial_direct 0.5

# node add/remove rates
node_add_prob = 0.2
node_delete_prob = 0.2

最后,考虑到种群的大小和物种可能的大小,我们减少了允许繁殖的个体比例(survival_threshold)。这种调整通过仅允许最健壮的有机体参与重组过程来限制解决方案的搜索空间:

[DefaultReproduction]
elitism = 2
survival_threshold = 0.1
min_species_size = 2

最后的调整是有争议的,并且可能会降低进化过程的整体性能。但在大型种群中,通过减少可能的重组数量通常效果良好。因此,作为经验法则,对于小型种群使用较大的生存阈值,对于大型种群使用较小的值。

由于本实验的复杂性增加,额外的超参数类型对于最终结果变得极其重要。神经进化过程是围绕突变发生的可能性构建的,突变概率与随机数生成器产生的值进行测试。

如您所知,在传统计算机中,没有真正的随机数源。相反,随机性是通过一个伪随机算法生成的,该算法高度依赖于随机种子来启动随机数序列的生成。实际上,随机种子值精确地定义了给定生成器将产生的所有伪随机数的序列。

因此,我们可以将随机种子数视为一个定义初始条件的本质参数。此参数设置了随机吸引子的属性,这将放大算法在数值搜索空间中的微小变化。放大的效果最终决定了算法是否能够找到最优解以及需要多长时间。

随机种子值在two_pole_markov_experiment.py文件的大约第100行定义:

# set random seed
seed = 1559231616
random.seed(seed)

对于双极*衡实验中使用的完整超参数列表,请参阅与此章节相关的源代码仓库中的two_pole_markov_config.ini文件。

上述代码设置了Python环境中提供的标准随机数生成器的种子值。

工作环境设置

双极*衡实验的工作环境可以通过在您选择的终端应用程序中输入以下命令来设置:

$ conda create --name double_pole_neat python=3.5
$ conda activate double_pole_neat
$ pip install neat-python==0.92 
$ conda install matplotlib
$ conda install graphviz
$ conda install python-graphviz

这些命令创建并激活了一个Python 3.5版本的double_pole_neat虚拟环境。之后,安装了版本0.92的NEAT-Python库,以及我们的可视化工具所使用的其他依赖项。

实验运行器实现

实现基因组适应度评估的源代码与用于单杆*衡实验的源代码相似。主要区别在于它将引用另一个模拟环境以获取*衡步数的数量。因此,您可以参考two_pole_markov_experiment.py文件中的eval_fitness(net, max_bal_steps=100000)eval_genomes(genomes, config)函数的源代码以获取实现细节。

在这个实验中,我们引入了自适应学习,它将在进化过程中尝试找到正确的短杆长度。短杆的长度会改变系统的运动动力学。并非所有与特定长度的短杆结合的超参数组合都能产生成功的控制策略。因此,我们实现了一个顺序增加短杆长度的过程,直到找到解决方案:

# Run the experiment
pole_length = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]
num_runs = len(pole_length)
for i in range(num_runs):
    cart.LENGTH_2 = pole_length[i] / 2.0
    solved = run_experiment(config_path, n_generations=100, silent=False)
    print("run: %d, solved: %s, length: %f" % 
                                         (i + 1, solved, cart.LENGTH_2))
    if solved:
        print("Solution found in: %d run, short pole length: %f" % 
                                                 (i + 1, cart.LENGTH_2))
    break

请参考two_pole_markov_experiment.py文件以获取更多实现细节。

前面的代码使用不同的短杆长度值运行模拟,直到找到解决方案。

运行双杆*衡实验

在实现了双杆*衡模拟器、基因组适应度函数评估器和实验运行代码后,我们准备开始实验。进入包含two_pole_markov_experiment.py文件的目录,并执行以下命令:

$ python two_pole_markov_experiment.py

不要忘记使用以下命令激活适当的虚拟环境:

conda activate double_pole_neat

前面的命令将在NEAT算法的控制下启动进化过程,使用two_pole_markov_config.ini文件中指定的超参数,以及我们已经实现的购物车双杆装置模拟器。

96代之后,获胜的解决方案可以在第97代找到。最后一代的控制台输出类似于以下内容:

 ****** Running generation 97 ****** 

Population's average fitness: 0.27393 stdev: 0.10514
Best fitness: 1.00000 - size: (1, 6) - species 26 - id 95605

Best individual in generation 97 meets fitness threshold - complexity: (1, 6)

Best genome:
Key: 95605
Fitness: 1.0
Nodes:
 0 DefaultNodeGene(key=0, bias=7.879760594997953, response=1.0, activation=sigmoid, aggregation=sum)
Connections:
 DefaultConnectionGene(key=(-6, 0), weight=1.9934757746640883, enabled=True)
 DefaultConnectionGene(key=(-5, 0), weight=3.703109977745863, enabled=True)
 DefaultConnectionGene(key=(-4, 0), weight=-11.923951805881497, enabled=True)
 DefaultConnectionGene(key=(-3, 0), weight=-4.152166115226511, enabled=True)
 DefaultConnectionGene(key=(-2, 0), weight=-3.101569479910728, enabled=True)
 DefaultConnectionGene(key=(-1, 0), weight=-1.379602358542496, enabled=True)

Evaluating the best genome in random runs
Runs successful/expected: 1/1
SUCCESS: The stable Double-Pole-Markov balancing controller found!!!
Random seed: 1559231616
run: 1, solved: True, half-length: 0.050000
Solution found in: 1 run, short pole length: 0.100000

在控制台输出中,我们可以看到获胜的基因组大小为(1, 6),这意味着它只有一个非线性节点——输出节点,并且从六个输入节点到输出节点的连接是完整的。因此,我们可以假设控制器ANN的最小可能配置已被找到,因为它不包含任何隐藏节点,而是使用特定探索的连接权重来编码控制行为。此外,值得注意的是,在所有可能的短杆长度值列表中,找到了最小长度值的解决方案。

能够执行可靠控制策略的控制器ANN的配置如图所示:

双杆*衡控制器的ANN

适应度分数在代与代之间变化,如图所示:

双杆*衡实验的每一代的适应度分数

在我们想要了解进化如何运作的情况下,前面的图表很有趣。你可以看到,在找到胜者之前,适应度分数会急剧下降。这是由于那些达到中等高适应度分数并显示在过去15代中没有改进的停滞物种的灭绝。之后,空缺的位置被拥有灭绝物种积累的遗传知识的全新物种占据。这个新生物种还引入了一种有益的突变,将它的遗传知识与新的技巧相结合,最终产生了胜者。

在这个实验中,我们决定通过显著增加种群规模并对超参数进行其他调整来增强物种的多样性。在下面的图表中,你可以看到我们已经达到了我们的目标,神经进化过程会经历多种物种,直到找到解决方案:

图片

双极*衡实验的代际物种分化

接下来,我们想了解随机种子数值的变化对NEAT算法的影响。首先,我们只将随机种子数值增加了一(其他一切都没有改变)。在这种新条件下,NEAT算法仍然可以找到稳定的控制策略,但创建了一个与之前展示的优化配置不同的、奇异的控制器ANN配置:

图片

随机种子数值增加一个(其他一切未变)的双极*衡控制器ANN

当随机种子数值增加,例如增加10时,神经进化过程根本找不到任何稳定的控制策略。

这个实验揭示了基于神经进化方法的一个另一个重要方面——由随机种子数值确定的初始条件的影响。随机种子定义了随机吸引子的属性,它放大了进化过程的效果,无论是好是坏。因此,在这个实验中,找到合适的随机种子数值以启动神经进化过程至关重要。我们将在本书的末尾讨论寻找合适的随机种子数值的方法。

练习

  1. 尝试在配置文件中将node_add参数值设置为0.02,看看会发生什么。

  2. 改变随机数生成器的种子值,看看会发生什么。是否找到了新的值解决方案?它与我们在本章中展示的内容有何不同?

摘要

在本章中,我们学习了如何实现控制策略,以维持一个带有顶部一个或两个极点的滑车装置的稳定状态。通过实现物理装置的精确模拟,我们提高了我们的Python技能,并扩展了我们对NEAT-Python库的知识,这些模拟被用来定义实验的目标函数。此外,我们还学习了两种微分方程的数值*似方法,即欧拉法和龙格-库塔法,并将它们实现到了Python中。

我们发现,决定神经进化过程的初始条件,例如随机种子数,对算法的性能有显著影响。这些值决定了随机数生成器将生成的整个数字序列。它们作为随机吸引子,可以放大或减弱进化的影响。

在下一章中,我们将讨论如何使用神经进化来创建能够通过迷宫的导航代理。你将学习如何定义一个以目标为导向的目标函数来解决迷宫问题,以及如何编写一个能够导航迷宫的机器人代理的精确模拟。我们将探讨两种迷宫环境类型,并检查以目标为导向的适应度函数如何在复杂的迷宫配置的欺骗性环境中陷入寻找解决方案的困境。

第七章:自主迷宫导航

迷宫导航是一个与自主导航领域密切相关的经典计算机科学问题。在本章中,你将了解如何使用基于神经进化的方法来解决迷宫导航的挑战。此外,我们将解释如何使用导航代理的适应度分数(作为代理与最终目标距离的导数)来定义以目标为导向的适应度函数。到本章结束时,你将了解使用神经进化方法训练自主导航代理的基本知识,并将能够创建下一章中将要介绍的更高级的迷宫求解器。你将熟悉高级可视化技术,这将使理解算法执行结果更容易。此外,你将获得使用Python编程语言编写迷宫导航机器人模拟器和相关迷宫环境的实践经验。

在本章中,你将熟悉以下主题:

  • 迷宫导航问题的欺骗性本质

  • 编写一个配备传感器和执行器的迷宫导航机器人模拟器

  • 定义一个以目标为导向的适应度函数,以指导使用神经进化算法创建适当的迷宫求解器的过程

  • 使用简单且难以解决的迷宫配置运行实验

技术要求

为了完成本章中描述的实验,应满足以下技术要求:

  • Windows 8/10,macOS 10.13或更新版本,或现代Linux

  • Anaconda Distribution版本2019.03或更新版本

本章的代码可以在https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/tree/master/Chapter5找到

迷宫导航问题

迷宫导航问题是一个与创建能够在模糊环境中找到路径的自主导航代理密切相关的经典计算机科学问题。迷宫环境是具有欺骗性适应度景观问题类的一个说明性领域。这意味着以目标为导向的适应度函数可以在迷宫中靠*最终目标点的死胡同中具有陡峭的适应度分数梯度。这些迷宫区域成为基于目标的搜索算法的局部最优解,这些算法可能会收敛到这些区域。当搜索算法收敛到这种欺骗性的局部最优解时,它无法找到适当的迷宫求解代理。

在以下示例中,你可以看到一个具有局部最优解的死胡同的二维迷宫,这些死胡同被阴影覆盖:

二维迷宫配置

图中的迷宫配置可视化了集中在局部最优死胡同(标记为填充段)中的欺骗性适应度分数景观。使用基于目标的搜索算法从起点(底部圆圈)导航到出口点(顶部圆圈)的迷宫求解代理将容易陷入局部最优死胡同。此外,像这样的欺骗性适应度分数景观可能会阻止基于目标的搜索算法找到成功的迷宫求解器。

在迷宫中导航的代理是一个配备了传感器组的机器人,它能够检测附*的障碍物并获得通往迷宫出口的方向。机器人的运动由两个执行器控制,它们影响机器人身体的线性运动和角运动。机器人的执行器由一个ANN控制,该ANN从传感器接收输入并产生两个控制信号供执行器使用。

迷宫模拟环境

迷宫模拟的环境由三个主要组件组成,这些组件作为独立的Python类实现:

  • Agent: 该类包含与迷宫导航代理相关的信息,该代理由模拟使用(请参阅agent.py文件以获取实现细节)。

  • AgentRecordStore: 管理与进化过程中所有求解代理评估相关的记录存储的类。收集的记录可以在完成后用于分析进化过程(请参阅agent.py文件以获取实现细节)。

  • MazeEnvironment: 包含迷宫模拟环境信息的类。此类还提供管理模拟环境、控制求解代理的位置、执行碰撞检测以及为代理的传感器生成输入数据的方法(请参阅maze_environment.py文件以获取实现细节)。

在以下章节中,我们将更详细地查看迷宫模拟环境的每个部分。

迷宫导航代理

在本章中,我们考虑一个二维迷宫导航任务。这个任务容易可视化,并且相对容易为二维迷宫编写迷宫导航机器人的模拟器。机器人的主要目标是导航通过迷宫,在指定的时间步数内到达定义的目标点。控制机器人的ANN是神经进化过程的产物。

神经进化算法从一个非常基本的初始ANN配置开始,该配置仅包含用于传感器的输入节点和用于执行器的输出节点,它逐渐变得更加复杂,直到找到成功的迷宫求解器。这个任务由于迷宫的特殊配置而变得复杂,该配置有几个死胡同,这通过在适应度景观中创建局部最优来阻止找到通往目标的路线,正如之前讨论的那样。

以下图显示了迷宫求解模拟中使用的迷宫代理的示意图:

图片

迷宫代理(机器人)架构

在前面的图中,实心圆定义了机器人的刚体。实心圆内的箭头表示机器人的航向。实心圆周围的六个箭头代表六个距离传感器,指示给定方向最*障碍物的距离。四个外圆段表示四个扇形雷达传感器,作为指向目标点(迷宫出口)的指南针。

当从目标点到机器人中心的线落在其视场内时,特定的雷达传感器会被激活。雷达传感器的检测范围由其视场内迷宫区域的大小限制。因此,在任何给定时间,四个雷达传感器中的一个是激活的,指示迷宫出口方向。

雷达传感器相对于机器人的航向有以下视场区域:

传感器 视场角,度
前方 315.0 ~ 405.0
左侧 45.0 ~ 135.0
后方 135.0 ~ 225.0
右侧 225.0 ~ 315.0

距离传感器是从机器人中心向特定方向绘制的一条射线。当与任何障碍物相交时,它会被激活,并返回检测到的障碍物的距离。该传感器的检测范围由特定的配置参数定义。

机器人的距离传感器监控相对于代理航向的以下方向:

传感器 方向,度
右侧 -90.0
右前方 -45.0
前方 0.0
左前方 45.0
左侧 90.0
后方 -180.0

机器人的运动由两个执行器控制,这些执行器施加力以旋转和/或推动代理框架,即改变其线性和/或角速度。

迷宫求解代理的Python实现具有多个字段,用于存储其当前状态并维护其传感器的激活状态:

    def __init__(self, location, heading=0, speed=0, 
                 angular_vel=0, radius=8.0, range_finder_range=100.0):
        self.heading = heading
        self.speed = speed
        self.angular_vel = angular_vel
        self.radius = radius
        self.range_finder_range = range_finder_range
        self.location = location
        # defining the range finder sensors
        self.range_finder_angles = [-90.0, -45.0, 0.0, 45.0, 90.0, -180.0]
        # defining the radar sensors
        self.radar_angles = [(315.0, 405.0), (45.0, 135.0),
                             (135.0, 225.0), (225.0, 315.0)]
        # the list to hold range finders activations
        self.range_finders = [None] * len(self.range_finder_angles)
        # the list to hold pie-slice radar activations
        self.radar = [None] * len(self.radar_angles)

更多实现细节,请参阅https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter5/agent.py文件。

前面的代码显示了Agent类的默认构造函数,其中初始化了代理的所有字段。迷宫环境模拟将使用这些字段在每个模拟步骤中存储代理的当前状态。

迷宫模拟环境实现

为了模拟求解代理在迷宫中导航,我们需要定义一个环境来管理迷宫的配置,跟踪迷宫求解代理的位置,并为导航机器人的传感器数据数组提供输入。

所有这些特性都集成在一个逻辑块中,该逻辑块封装在MazeEnvironment Python类中,该类具有以下字段(如类构造函数所示):

    def __init__(self, agent, walls, exit_point, exit_range=5.0):
        self.walls = walls
        self.exit_point = exit_point
        self.exit_range = exit_range
        # The maze navigating agent
        self.agent = agent
        # The flag to indicate if exit was found
        self.exit_found = False
        # The initial distance of agent from exit
        self.initial_distance = self.agent_distance_to_exit()

上述代码显示了MazeEnvironment类的默认构造函数及其所有字段的初始化:

  • 迷宫配置由墙壁列表和exit_point确定。墙壁是线段的列表;每个线段代表迷宫中的特定墙壁,而exit_point是迷宫出口的位置。

  • exit_range字段存储围绕exit_point的范围距离值,该值定义了出口区域。我们认为,当代理的位置在出口区域内时,代理已成功解决迷宫。

  • agent字段包含对前一小节中描述的初始化Agent类的引用,该类定义了求解代理在迷宫中的起始位置以及其他与代理相关的数据字段。

  • initial_distance字段存储从代理起始位置到迷宫出口点的距离。此值将用于计算代理的适应度分数。

传感器数据生成

迷宫求解代理由一个需要接收传感器数据作为输入以产生相应控制信号作为输出的ANN(人工神经网络)控制。正如我们之前提到的,导航代理装备有两组类型的传感器:

  • 六个测距传感器用于检测与迷宫墙壁的碰撞,它们指示特定方向上最*障碍物的距离。

  • 四个扇形雷达传感器,它们从迷宫中的任何位置指示通往迷宫出口点的方向。

传感器值需要在每个模拟步骤中更新,MazeEnvironment类提供了两个专门的方法来更新这两种类型的传感器。

测距传感器的数组更新如下(参见update_rangefinder_sensors函数):

        for i, angle in enumerate(self.agent.range_finder_angles):
            rad = geometry.deg_to_rad(angle)
            projection_point = geometry.Point(
                x = self.agent.location.x + math.cos(rad) * \
                    self.agent.range_finder_range,
                y = self.agent.location.y + math.sin(rad) * \
                    self.agent.range_finder_range
            )
            projection_point.rotate(self.agent.heading, 
                                    self.agent.location)
            projection_line = geometry.Line(a = self.agent.location, 
                                            b = projection_point)
            min_range = self.agent.range_finder_range
            for wall in self.walls:
                found, intersection = wall.intersection(projection_line)
                if found:
                    found_range = intersection.distance(
                                                   self.agent.location)
                    if found_range < min_range:
                        min_range = found_range
            # Store the distance to the closest obstacle
            self.agent.range_finders[i] = min_range

此代码列出了测距传感器的所有检测方向,这些方向由方向角度确定(参见Agent构造函数中的range_finder_angles字段初始化)。对于每个方向,然后创建一条从代理当前位置开始,长度等于测距器检测范围的投影线。之后,测试投影线是否与迷宫墙壁相交。如果检测到多个交点,则将最*墙壁的距离存储为特定测距传感器的值。否则,将最大检测范围保存为测距传感器的值。

MazeEnvironment类中,需要使用以下代码更新扇形雷达传感器的数组:

    def update_radars(self):
        target = geometry.Point(self.exit_point.x, self.exit_point.y)
        target.rotate(self.agent.heading, self.agent.location)
        target.x -= self.agent.location.x
        target.y -= self.agent.location.y
        angle = target.angle()
        for i, r_angles in enumerate(self.agent.radar_angles):
            self.agent.radar[i] = 0.0 # reset specific radar 
            if (angle >= r_angles[0] and angle < r_angles[1]) or 
               (angle + 360 >= r_angles[0] and angle + 360 < r_angles[1]):
                # fire the radar
                self.agent.radar[i] = 1.0

上述代码创建迷宫出口点的副本,并在全局坐标系中相对于代理的航向和位置进行旋转。然后,目标点被*移以使其与迷宫求解代理的局部坐标系对齐;代理被放置在坐标原点。之后,我们计算在代理局部坐标系中从坐标原点到目标点的向量的角度。这个角度是从当前代理位置到迷宫出口点的方位角。当找到方位角时,我们遍历已注册的扇形雷达传感器,以找到包含方位角在其视场内的一个。相应的雷达传感器通过将其值设置为 1 被激活,而其他雷达传感器通过将其值置零被停用。

代理位置更新

在从控制器 ANN 接收到相应的控制信号后,迷宫求解代理在迷宫中的位置需要在每个模拟步骤中进行更新。以下代码用于更新迷宫求解代理的位置:

    def update(self, control_signals):
        if self.exit_found:
            return True # Maze exit already found
        self.apply_control_signals(control_signals)
        vx = math.cos(geometry.deg_to_rad(self.agent.heading)) * \
                      self.agent.speed
        vy = math.sin(geometry.deg_to_rad(self.agent.heading)) * \
                      self.agent.speed
        self.agent.heading += self.agent.angular_vel
        if self.agent.heading > 360:
            self.agent.heading -= 360
        elif self.agent.heading < 0:
            self.agent.heading += 360
        new_loc = geometry.Point(
            x = self.agent.location.x + vx, 
            y = self.agent.location.y + vy
        )
        if not self.test_wall_collision(new_loc):
            self.agent.location = new_loc
        self.update_rangefinder_sensors()
        self.update_radars()
        distance = self.agent_distance_to_exit()
        self.exit_found = (distance < self.exit_range)
        return self.exit_found

update(self, control_signals) 函数定义在 MazeEnvironment 类中,并在每个模拟时间步被调用。它接收一个包含控制信号的列表作为输入,并返回一个布尔值,指示迷宫求解代理在其位置更新后是否已到达出口区域。

此函数开头代码将接收到的控制信号应用于代理的角速度和线速度的当前值,如下所示(参见 apply_control_signals(self, control_signals) 函数):

       self.agent.angular_vel += (control_signals[0] - 0.5)
       self.agent.speed += (control_signals[1] - 0.5)

之后,计算 xy 速度分量以及代理的航向,并用于估计其在迷宫中的新位置。如果这个新位置没有与迷宫的任何墙壁发生碰撞,那么它将被分配给代理并成为其当前位置:

        vx = math.cos(geometry.deg_to_rad(self.agent.heading)) * \
                      self.agent.speed
        vy = math.sin(geometry.deg_to_rad(self.agent.heading)) * \
                      self.agent.speed
        self.agent.heading += self.agent.angular_vel
        if self.agent.heading > 360:
            self.agent.heading -= 360
        elif self.agent.heading < 0:
            self.agent.heading += 360
        new_loc = geometry.Point(
            x = self.agent.location.x + vx, 
            y = self.agent.location.y + vy
        )
        if not self.test_wall_collision(new_loc):
            self.agent.location = new_loc

在此之后,新代理位置被用于以下函数中,这些函数更新测距仪和雷达传感器,以估计下一次时间步的新传感器输入:

        self.update_rangefinder_sensors()
        self.update_radars()

最后,以下函数测试代理是否已到达迷宫出口,该出口由出口点周围的圆形区域定义,其半径等于 exit_range 字段的值:

        distance = self.agent_distance_to_exit()
        self.exit_found = (distance < self.exit_range)
        return self.exit_found

如果已到达迷宫出口,则将 exit_found 字段的值设置为 True 以指示任务的顺利完成,并从函数调用中返回其值。

对于更多实现细节,请参阅 https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter5/maze_environment.py 中的 maze_environment.py 文件。

代理记录存储

在完成实验后,我们感兴趣的是评估和可视化每个个体求解代理在整个进化过程中的表现。这是通过在指定的时间步数后运行迷宫求解模拟来收集每个代理的额外统计数据来实现的。代理记录的收集是通过两个Python类AgentRecordAgentRecordStore来介导的。

AgentRecord类由几个数据字段组成,如下所示,可以在类构造函数中看到:

    def __init__(self, generation, agent_id):
        self.generation = generation
        self.agent_id = agent_id
        self.x = -1
        self.y = -1
        self.fitness = -1
        self.hit_exit = False
        self.species_id = -1
        self.species_age = -1

字段定义如下:

  • generation包含创建代理记录时的代ID。

  • agent_id是代理的唯一标识符。

  • xy是代理在模拟完成后在迷宫中的位置。

  • fitness是代理的最终适应度得分。

  • hit_exit是一个标志,表示代理是否已达到迷宫出口区域。

  • species_idspecies_age是代理所属物种的ID和年龄。

AgentRecordStore类包含代理记录列表,并提供从/到特定文件加载/转储收集到的记录的功能。

请参阅源代码库中与此章节相关的目录中的agent.py文件以获取完整的实现细节。

在基因组适应度评估后,将新的AgentRecord实例添加到存储中,如maze_experiment.py文件中实现的eval_fitness(genome_id, genome, config, time_steps=400)函数所定义的。这是通过以下代码完成的:

def eval_fitness(genome_id, genome, config, time_steps=400):
    maze_env = copy.deepcopy(trialSim.orig_maze_environment)
    control_net = neat.nn.FeedForwardNetwork.create(genome, config)
    fitness = maze.maze_simulation_evaluate(
              env=maze_env, net=control_net, time_steps=time_steps)
    record = agent.AgentRecord(
        generation=trialSim.population.generation,
        agent_id=genome_id)
    record.fitness = fitness
    record.x = maze_env.agent.location.x
    record.y = maze_env.agent.location.y
    record.hit_exit = maze_env.exit_found
    record.species_id = trialSim.population.species.\
                                       get_species_id(genome_id)
    record.species_age = record.generation - \
      trialSim.population.species.get_species(genome_id).created
    trialSim.record_store.add_record(record)
    return fitness

此代码首先创建原始迷宫环境的深度副本,以避免评估运行之间的干扰。之后,它使用提供的NEAT配置从指定的基因组创建控制ANN,并开始对给定的时间步数进行迷宫模拟评估。然后,代理的返回适应度得分以及其他统计数据被存储到特定的AgentRecord实例中,并添加到记录存储中。

实验一次试验期间收集的记录将被保存到output目录下的data.pickle文件中,并用于可视化所有评估代理的性能。

请参阅maze_experiment.py文件以获取完整的实现细节:https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter5/maze_experiment.py.

代理记录可视化

在神经进化过程中收集所有代理的评估记录后,我们感兴趣的是可视化这些记录以了解性能。可视化应包括所有求解代理的最终位置,并允许设置物种的适应度阈值以控制哪些物种将被添加到相应的图中。我们决定将收集到的代理记录以两个图的形式展示,一个在上,一个在下。顶部图是适应度分数大于或等于指定适应度阈值的代理记录,底部图是其余的记录。

代理记录的可视化是在visualize.py脚本中的新方法中实现的。你应该已经熟悉这个脚本,因为它来自本书中描述的先前实验。

请查看位于https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter5/visualize.pyvisualize.py文件中的draw_maze_records(maze_env, records, best_threshold=0.8, filename=None, view=False, show_axes=False, width=400, height=400)函数定义。

使用适应度分数定义目标函数

在本节中,你将了解如何使用以目标为导向的目标函数来引导进化过程,创建成功的迷宫求解代理。这个目标函数基于通过测量代理执行400个模拟步骤后其最终位置与迷宫出口之间的距离来估计迷宫求解器的适应度分数。因此,目标函数是目标导向的,并且仅依赖于实验的最终目标:到达迷宫出口区域。

在下一章中,我们将考虑一种不同的解决方案搜索优化方法,该方法基于新颖性搜索NS)优化方法。NS优化方法围绕在进化过程中探索求解代理的新配置而构建,并在目标函数定义中不包括接*最终目标(在这种情况下,迷宫出口)的邻*性。我们将证明NS方法可以优于本章中考虑的传统以目标为导向的目标函数定义。

在这个实验中使用的以目标为导向的目标函数如下确定。首先,我们需要将损失函数定义为代理在模拟结束时最终位置与迷宫出口位置的欧几里得距离

图片

图片描述 是损失函数,图片描述 是智能体最终位置的坐标,而 图片描述 是迷宫出口的坐标。在这个实验中,我们考虑了二维迷宫配置,因此坐标有两个值,每个维度一个。

使用之前定义的损失函数,我们现在可以指定适应度函数:

图片描述

图片描述 是迷宫出口点周围出口区域的半径,而 图片描述 是归一化适应度分数。归一化适应度分数的给定如下:

图片描述

图片描述 是导航模拟开始时求解智能体到迷宫出口的初始距离。

该方程将适应度分数归一化到 (0,1] 范围内,但在罕见情况下,当智能体的最终位置远离其初始位置和迷宫出口时,可能会产生负值。以下对归一化适应度分数的修正将应用于避免负值:

图片描述

当适应度分数小于或等于 0.01 时,它将被赋予支持的最小适应度分数值(0.01);否则,它将按原值使用。我们选择的最小适应度分数高于零,以给每个基因组提供繁殖的机会。

以下 Python 代码实现了以目标为导向的目标函数:

    # Calculate the fitness score based on distance from exit
    fitness = env.agent_distance_to_exit()
    if fitness <= self.exit_range:
         fitness = 1.0
    else:
        # Normalize fitness score to range (0,1]
        fitness = (env.initial_distance - fitness) / \
                   env.initial_distance
        if fitness <= 0.01:
            fitness = 0.01

代码首先调用 agent_distance_to_exit() 函数,该函数计算当前智能体位置到迷宫出口的欧几里得距离,并使用返回值作为适应度分数的第一个*似值。之后,将适应度分数(到迷宫出口的距离)与出口范围值进行比较。如果适应度分数小于或等于出口范围值,我们将其赋予最终的值 1.0。否则,归一化适应度分数通过将智能体到迷宫出口的最终距离与初始距离之差除以初始距离来计算。有时,这可能导致归一化适应度值为负,这可以通过将适应度值与 0.01 进行比较并进行必要的修正来纠正。

完整的实现细节请参阅 maze_environment.py 脚本。

使用简单的迷宫配置运行实验

我们以简单的迷宫配置开始我们关于创建成功迷宫导航智能体的实验。虽然简单的迷宫配置具有之前讨论过的欺骗性 局部最优死胡同,但它从起点到出口点的路径相对直接。

以下图表表示了本实验使用的迷宫配置:

图片

简单的迷宫配置

图中的迷宫有两个特定的位置用实心圆圈标记。左上角的圆圈表示迷宫导航代理的起始位置。右下角的圆圈标记了需要找到的迷宫出口的确切位置。迷宫求解器需要到达由其周围的特定出口范围区域表示的迷宫出口点附*,以完成任务。

超参数选择

根据目标函数的定义,通过到达迷宫出口区域可以获得的最大导航代理适应度分数是1.0。我们还期望控制器人工神经网络的初始配置比书中前面描述的实验更复杂,这将影响算法的执行速度。因此,在一个中等配置的PC上完成具有显著大量基因组群体的神经进化算法将花费太长时间。但与此同时,当前的任务比以前的实验更复杂,需要使用更宽的搜索区域以成功探索解决方案。因此,通过试错,我们发现可以将种群大小设置为250

以下是从配置文件中摘录的包含我们刚刚讨论的参数定义的部分:

[NEAT]
fitness_criterion = max
fitness_threshold = 1.0
pop_size = 250
reset_on_extinction = False

表型人工神经网络的初始配置包括10个输入节点、2个输出节点和1个隐藏节点。输入和输出节点对应于输入传感器和控制信号输出。隐藏节点从神经进化过程开始就引入非线性,并为进化过程节省时间以发现它。人工神经网络配置如下:

num_hidden = 1
num_inputs = 10
num_outputs = 2

为了扩展解决方案搜索区域,我们需要提高种群的物种分化,以在有限代数内尝试不同的基因组配置。这可以通过降低兼容性阈值或增加用于计算基因组兼容性分数的系数值来实现。

在这个实验中,我们使用了两种修正,因为适应度函数的地形具有欺骗性,我们需要强调基因组配置中的微小变化以创建新的物种。以下配置参数受到影响:

[NEAT]
compatibility_disjoint_coefficient = 1.1
[DefaultSpeciesSet]
compatibility_threshold = 3.0

我们特别关注创建一个迷宫求解控制人工神经网络的最佳配置,该配置具有最少的隐藏节点和连接。通过神经进化过程,最佳人工神经网络配置在迷宫求解模拟器的训练阶段以及推理阶段都更节省计算资源。通过减少添加新节点的可能性,可以产生最佳人工神经网络配置,如下所示,这是从NEAT配置文件中的片段:

node_add_prob          = 0.1
node_delete_prob       = 0.1

最后,我们允许神经进化过程不仅利用具有前馈连接的ANN配置,还包括循环连接。通过循环连接,我们使ANN具有记忆功能,成为一个状态机。这对进化过程来说是有益的。以下配置超参数控制了这一行为:

feed_forward            = False

本节中描述的超参数被发现对实验中使用的NEAT算法有益,该算法在有限代数内创建了一个成功的迷宫求解代理。

对于简单迷宫求解实验中使用的完整超参数列表,请参阅https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter5/maze_config.ini中的maze_config.ini文件。

迷宫配置文件

我们的实验迷宫配置以纯文本形式提供。此文件被加载到模拟环境中,相应的迷宫配置便被实例化。配置文件的内容类似于以下内容:

11
30 22
0
270 100
5 5 295 5
295 5 295 135
295 135 5 135
…

迷宫配置文件的格式如下:

  • 第一行包含迷宫中的墙壁数量。

  • 第二行确定代理的起始位置(xy)。

  • 第三行表示代理的初始航向角度(以度为单位)。

  • 第四行包含迷宫出口位置(xy)。

  • 以下行定义了迷宫的墙壁。迷宫墙壁的数量由文件中的第一个数字给出。

迷宫墙壁以线段的形式呈现,前两个数字定义了起点的坐标,最后两个数字确定了终点的坐标。代理的起始位置和迷宫出口以两个数字的形式表示,这两个数字指示了二维空间中某点的 xy 坐标。

工作环境设置

使用以下命令在您选择的终端应用程序中设置简单迷宫求解实验的工作环境:

$ conda create --name maze_objective_neat python=3.5
$ conda activate maze_objective_neat
$ pip install neat-python==0.92 
$ conda install matplotlib
$ conda install graphviz
$ conda install python-graphviz

这些命令使用Python 3.5创建并激活了一个maze_objective_neat虚拟环境。之后,安装了版本为0.92的NEAT-Python库,以及我们可视化工具所用的其他依赖。

之后,我们就可以开始实验运行器的实现了。

实验运行器实现

实验运行器在maze_experiment.py文件中实现,您应该参考该文件以获取完整的实现细节。此Python脚本提供了读取命令行参数、配置和启动神经进化过程以及完成后渲染实验结果的功能。它还包括评估属于特定群体的基因组健壮性的回调函数的实现。这些回调函数将在初始化NEAT-Python库环境时提供。

此外,我们讨论了实验运行器实现中的关键部分,这些部分在本章之前未涉及:

  1. 我们首先使用以下行初始化迷宫模拟环境:
    maze_env_config = os.path.join(local_dir, '%s_maze.txt' % 
                                   args.maze)
    maze_env = maze.read_environment(maze_env_config)

args.maze 指的是用户在启动Python脚本时提供的命令行参数,它指的是我们想要实验的迷宫环境类型。它可以有两个值:中等困难。前者指的是我们在本次实验中使用的简单迷宫配置。

  1. 之后,我们为随机数生成器设置了特定的种子数,创建了NEAT配置对象,并使用创建的配置对象创建了neat.Population对象:
    seed = 1559231616
    random.seed(seed)
    config = neat.Config(neat.DefaultGenome, 
                         neat.DefaultReproduction,
                         neat.DefaultSpeciesSet, 
                         neat.DefaultStagnation,
                         config_file)
    p = neat.Population(config)

偶然的是,在双杆*衡实验中找到的随机种子值也适用于这个实验。我们可以假设我们找到了一个针对NEAT-Python库实现的随机过程的特定随机吸引子。在本书的后面,我们将检查这同样适用于其他实验。

  1. 现在我们已经准备好创建适当的迷宫模拟环境,并将其存储为全局变量,以简化从健身评估回调函数中对它的访问:
    global trialSim
    trialSim = MazeSimulationTrial(maze_env=maze_env, 
                                   population=p)

MazeSimulationTrial对象包含字段,提供对原始迷宫模拟环境和用于保存迷宫求解代理评估结果的记录存储器的访问。在每次调用健身评估回调函数eval_fitness(genome_id, genome, config, time_steps=400)时,原始迷宫模拟环境将被复制,并将用于特定求解代理在400个时间步内进行迷宫求解模拟。之后,将从环境中收集关于迷宫求解代理的完整统计数据,包括其在迷宫中的最终位置,并将其添加到记录存储器中。

  1. 以下代码已成为我们实验的标准,它与添加各种统计报告器相关:
    p.add_reporter(neat.StdOutReporter(True))
    stats = neat.StatisticsReporter()
    p.add_reporter(stats)
    p.add_reporter(neat.Checkpointer(5, 
                 filename_prefix='%s/maze-neat-checkpoint-' % 
                 trial_out_dir))

报告器用于在控制台显示神经进化过程的中间结果,以及收集在过程完成后将渲染的更详细统计数据。

  1. 最后,我们运行指定代数的神经进化过程,并检查是否找到了解决方案:
    start_time = time.time()
    best_genome = p.run(eval_genomes, n=n_generations)
    elapsed_time = time.time() - start_time
    solution_found = (best_genome.fitness >= \
                      config.fitness_threshold)
    if solution_found:
        print("SUCCESS: The stable maze solver controller was found!!!")
    else:
        print("FAILURE: Failed to find the stable maze solver controller!!!")

我们假设如果NEAT-Python库返回的最佳基因组的适应性评分大于或等于配置文件中设置的适应性阈值值(1.0),则已找到解决方案。计算已用时间以打印完成过程所需的时间。

基因组适应性评估

评估属于特定生物种群的所有基因组的适应性评分的回调函数实现如下:

def eval_genomes(genomes, config):
    for genome_id, genome in genomes:
        genome.fitness = eval_fitness(genome_id, genome, config)

eval_fitness(genome_id, genome, config)函数通过运行迷宫求解模拟来评估特定基因组的适应性。此函数的实现在此处未提供,因为它已在本章中讨论过。

运行简单迷宫导航实验

在实现了迷宫求解模拟器、实验运行器和适应性评估回调函数之后,我们就可以开始迷宫求解实验了。请确保你将所有相关的Python脚本和配置文件(maze_config.inimedium_maze.txt)复制到工作目录中。

然后,进入此目录并从你选择的终端应用程序中执行以下命令:

$ python maze_experiment.py -m medium -g 150

不要忘记使用以下命令激活适当的虚拟环境:

conda activate maze_objective_neat

之前的命令从medium_maze.txt文件加载简单迷宫配置并创建相应的迷宫模拟环境。之后,它启动了在NEAT算法控制下的神经进化过程,使用maze_config.ini文件中指定的超参数。NEAT算法使用迷宫求解模拟环境来评估神经进化过程中产生的每个基因组的适应性,共150代。

(命令行参数中的-g)。

经过144代的进化后,在145代找到了成功的迷宫求解代理。最后一代的控制台输出如下:

  1. 首先,关于基因组种群的一般统计信息:
****** Running generation 145 ****** 

Maze solved in 388 steps
Population's average fitness: 0.24758 stdev: 0.25627
Best fitness: 1.00000 - size: (3, 11) - species 7 - id 35400

Best individual in generation 145 meets fitness threshold - complexity: (3, 11)
  1. 其次,配置编码成功迷宫求解控制器ANN的基因组:
Best genome:
Key: 35400
Fitness: 1.0
Nodes:
 0 DefaultNodeGene(key=0, bias=5.534849614521037, response=1.0, activation=sigmoid, aggregation=sum)
 1 DefaultNodeGene(key=1, bias=1.8031133229851957, response=1.0, activation=sigmoid, aggregation=sum)
 158 DefaultNodeGene(key=158, bias=-1.3550878188609456, response=1.0, activation=sigmoid, aggregation=sum)
Connections:
 DefaultConnectionGene(key=(-10, 158), weight=-1.6144052085440168, enabled=True)
 DefaultConnectionGene(key=(-8, 158), weight=-1.1842193888036392, enabled=True)
 DefaultConnectionGene(key=(-7, 0), weight=-0.3263706518456319, enabled=True)
 DefaultConnectionGene(key=(-7, 1), weight=1.3186165993348418, enabled=True)
 DefaultConnectionGene(key=(-6, 0), weight=2.0778575294986945, enabled=True)
 DefaultConnectionGene(key=(-6, 1), weight=-2.9478037554862824, enabled=True)
 DefaultConnectionGene(key=(-6, 158), weight=0.6930281879212032, enabled=True)
 DefaultConnectionGene(key=(-4, 1), weight=-1.9583885391583729, enabled=True)
 DefaultConnectionGene(key=(-3, 1), weight=5.5239054588484775, enabled=True)
 DefaultConnectionGene(key=(-1, 0), weight=0.04865917999517305, enabled=True)
 DefaultConnectionGene(key=(158, 0), weight=0.6973191076874032, enabled=True)
SUCCESS: The stable maze solver controller was found!!!
Record store file: out/maze_objective/medium/data.pickle

在控制台输出中,你可以看到在进化过程中找到了成功的迷宫求解控制器,并且能够在400步中达到迷宫出口区域,共388步。成功迷宫求解控制器的控制ANN配置包括2个输出节点和1个隐藏节点,节点之间和从输入到节点之间有11个连接。控制器ANN的最终配置如下所示:

图片

控制简单迷宫求解成功求解器的ANN配置

研究不同传感器输入如何影响输出控制信号是非常有趣的。我们可以看到,神经网络配置完全忽略了来自前方和左侧测距仪传感器(RF_FRRF_L)以及来自机器人后向饼形雷达传感器(RAD_B)的输入。同时,机器人的线性和角速度由其他传感器的独特组合控制。

此外,我们可以通过隐藏节点看到左右饼形雷达传感器(RAD_LRAD_R)与后向测距仪(RF_B)的聚合,该节点随后将聚合信号传递给控制角速度的节点。如果我们看看本章中显示的简单迷宫配置图像(参见简单迷宫配置图像),这种聚合看起来相当自然。这使得当机器人陷入死胡同,局部最优解所在之处时,它可以转身并继续探索迷宫。

在这里展示了求解代理的适应度分数随代数的变化:

图片

各代*均适应度分数

在这个图中,我们可以看到进化过程在第 44 代成功产生了具有适应度分数 0.96738 的迷宫求解代理。但要经过额外的 100 代才能进化出编码成功迷宫求解者代理的神经网络的基因组。

此外,值得注意的是,在第 44 代性能提升是由物种 ID 为 1 的物种产生的,但成功迷宫求解者的基因组属于 ID 为 7 的物种,这在第一次峰值时甚至还不为人知。产生冠军物种的物种在第 12 代出现,并一直保留在种群中,保留了有益的突变并对其进行了完善。

在以下图中展示了各代物种分化:

图片

各代物种分化

在物种分化图中,我们可以看到用粉红色标记的 ID 为 7 的物种。这个物种在进化过程中最终产生了成功迷宫求解者的基因组。物种 7 的大小在其生命周期中变化很大,一度在几代(从 105108)中成为整个种群中唯一的物种。

代理记录可视化

在这个实验中,我们提出了一种新的可视化方法,使我们能够直观地辨别进化过程中各种物种的性能。可视化可以通过以下命令执行,该命令在实验的工作目录中执行:

$ python visualize.py -m medium -r out/maze_objective/medium/data.pickle --width 300 --height 150

命令加载了每个迷宫解决代理在进化过程中的适应度评估记录,这些记录存储在data.pickle文件中。之后,它将在迷宫解决模拟结束时在迷宫地图上绘制代理的最终位置。每个代理的最终位置以颜色编码的圆圈表示。圆圈的颜色编码了特定代理所属的物种。进化过程中产生的每个物种都有一个独特的颜色编码。以下图表显示了可视化结果:

图片

解决代理评估的可视化

为了使可视化更具信息量,我们引入了适应度阈值来过滤掉表现最出色的物种。顶部子图显示了冠军物种(适应度分数高于0.8)所属的解决代理的最终位置。正如你所见,属于这六个物种的有机体是活跃的探险者,它们具有在迷宫中搜索未知地方的基因。它们的最终位置几乎均匀地分布在起始点周围的迷宫区域内,并且在局部最优的死胡同处密度较低。

同时,你可以在底部子图中看到,进化失败的个体表现出更保守的行为,主要集中在新区域的墙壁附*以及最强的局部最优区域——迷宫底部的最大死胡同。

练习

  1. 尝试在maze_config.ini文件中增加compatibility_disjoint_coefficient参数,并使用新设置运行实验。这种修改对进化过程中产生的物种数量有何影响?神经进化过程能否找到成功的迷宫解决者?

  2. 将种群大小增加200%(pop_size参数)。在这种情况下,神经进化过程能否找到解决方案?如果能,需要多少代?

  3. 改变随机数生成器的种子值(见maze_experiment.py文件的第118行)。使用这个新值,神经进化过程能否成功?

使用难以解决的迷宫配置运行实验

本章的下一个实验是运行神经进化过程,以找到能够解决具有更复杂墙壁配置的迷宫的代理。这个难以解决的迷宫配置引入了强大的局部适应度最优陷阱,并且从代理的起始位置到迷宫出口区域没有直接的路线。你可以在以下图表中看到迷宫配置:

图片

难以解决的迷宫配置

迷宫配置的起始位置在左下角,用绿色圆圈标记,迷宫出口点的位置在左上角,用红色圆圈标记。你可以看到,为了解决迷宫,导航代理必须发展一种复杂控制策略,使其能够避开起点的局部适应度最优陷阱。控制策略需要能够遵循从起点到出口的复杂轨迹,该轨迹有几个转弯和更多的局部最优陷阱。

超参数选择

对于这个实验,我们将使用与简单迷宫求解实验中相同的超参数。我们的想法是让神经进化算法具有相同的初始条件,并观察它是否能够进化出一个成功的求解代理代理,用于不同的、更复杂的迷宫配置。这将表明算法使用不同迷宫配置的超参数设置进行泛化的程度。

工作环境设置和实验运行实现

简单迷宫导航实验的工作环境设置与实验运行实现保持相同。实验运行实现也保持不变。我们只更改描述迷宫环境配置的文件。

运行难以解决的迷宫导航实验

正如我们提到的,我们将使用与之前实验相同的实验运行实现和相同的NEAT超参数设置。但我们将配置不同的迷宫环境如下:

$ python maze_experiment.py -m hard -g 500

过了一段时间,当实验结束后,我们发现即使经过500代的进化,仍未找到成功的迷宫求解器。使用神经进化算法获得的最佳基因组编码了一个奇特且非功能性的控制器ANN配置,如下图所示:

图片

控制难以解决的迷宫求解器的ANN配置

从图中可以看出,机器人的旋转仅依赖于前视测距传感器(RF_FR),而线性运动则由多个测距传感器和雷达传感器的组合控制。这种控制配置导致机器人的线性运动在检测到前方有墙壁之前被简化。当我们查看代理评估记录的可视化时,我们的关于运动模式的假设得到了证实:

图片

求解代理评估记录的可视化

求解代理最终位置的可视化表明,大多数物种被困在起始位置周围,那里有一些局部适应度得分最优区域。没有任何物种能够显示出超过我们阈值(0.8)的适应度得分。此外,正如我们之前提到的,求解代理的最终位置形成了明显的垂直线(灰色点形成垂直列)。这证实了我们在进化过程中找到的最佳基因组编码的控制器ANN配置不正确这一假设。

在以下图表中显示了多代*均适应度得分:

图片

多代*均适应度得分

在*均适应度得分的图表中,我们可以看到神经进化过程能够在第一代中显著提高求解代理的适应度得分,但之后达到了一个*台期,没有显示出任何改进。这意味着增加进化代数数量没有任何意义,需要采取其他措施来提高神经进化过程的表现。

练习

  1. 尝试通过调整maze_config.ini文件中的pop_size参数来增加种群大小。这有助于神经进化过程进化出一个成功的迷宫求解器吗?

这可能需要很长时间才能执行。

摘要

在本章中,你了解了一类使用具有欺骗性定义景观的目标导向适应度函数的规划和控制问题。在这个景观中,由适应度函数的局部最优区域创建了多个陷阱,误导了解决搜索过程,该过程仅基于作为代理到目标距离的导数的适应度得分。你了解到传统的目标导向适应度函数可以帮助搜索过程为简单的迷宫配置创建一个成功的迷宫导航代理,但由于局部最优陷阱,在更复杂的迷宫中失败了。

我们提出了一种有用的可视化方法,使我们能够可视化迷宫地图上所有评估代理的最终位置。通过这种可视化,你可以对进化过程的性能做出假设。然后,你可以做出关于配置设置更改的决定,这可能导致进一步的性能提升。

此外,你已经了解到,当局部最优解中适应度函数收敛的可能性更高时,神经进化过程往往会产生更少的物种。在极端情况下,它只创建一个物种,这阻碍了创新并阻碍了进化过程。为了避免这种情况,你学习了如何通过改变兼容性不交系数的值来加速物种形成,该系数用于计算基因组兼容性因子。这个系数控制着将被分配给比较的基因组中过剩或不交部分的权重。较高的系数值增加了比较基因组中拓扑差异的重要性,并允许更多样化的基因组属于同一物种。

在下一章中,我们将介绍NS优化方法,该方法在解决诸如迷宫导航等欺骗性任务方面表现更佳。

第八章:新颖性搜索优化方法

在本章中,你将了解一种高级的解决方案搜索优化方法,该方法可用于创建自主导航代理。这种方法被称为新颖性搜索NS)。这种方法的主要思想是,可以使用求解代理暴露的行为的新颖性来定义目标函数,而不是在解决方案搜索空间中到目标的距离。

在本章中,你将学习如何使用基于NS的搜索优化方法与神经进化算法一起训练成功的迷宫导航代理。通过本章中展示的实验,你还将看到NS方法在特定任务中优于传统的以目标为导向的搜索优化方法。到本章结束时,你将掌握NS优化方法的基础知识。你将能够使用新颖性得分来定义适应度函数,并将其应用于解决与你的工作或实验相关的实际任务。

本章将涵盖以下主题:

  • NS优化方法

  • NS实现基础

  • 带有新颖性得分的适应度函数

  • 尝试简单的迷宫配置

  • 尝试难以解决的迷宫配置

技术要求

为了执行本章中描述的实验,应满足以下技术要求:

  • Windows 8/10, macOS 10.13或更高版本,或现代Linux

  • Anaconda Distribution版本2019.03或更高版本

本章的代码可以在https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/tree/master/Chapter6找到

NS优化方法

NS背后的主要思想是奖励产生解决方案的新颖性,而不是其向最终目标的进展。这个想法受到了自然进化的启发。在寻找成功解决方案时,并不总是明显应该采取的确切步骤。自然进化不断地产生新颖的形式,不同的表型试图利用周围环境并适应变化。这导致了地球上生命形式的爆炸性增长,并推动了生命进化的质变。同样的过程使得生命形式从海洋中离开并征服陆地。真核生物的非凡起源成为了地球上所有更高级生命形式的源头。所有这些都是进化过程中奖励新颖性的例子。同时,在自然进化中,没有明确的目标或最终目标。

正如你在上一章所学,传统的以目标为导向的适应度函数容易陷入局部最优陷阱。这种病理学给进化过程施加压力,使其收敛到单一解,这通常会导致搜索空间中的死胡同,没有局部步骤可以进一步提高适应度函数的性能。因此,结果是,成功的解决方案未被探索。

另一方面,NS推动进化朝着多样性发展。这种推动力有助于神经进化过程产生成功的求解器代理,即使对于具有欺骗性适应度函数值的任务,如迷宫导航问题也是如此。

一个这样的欺骗性问题的现实例子是绕过一个未知城市的导航任务。如果你访问的是带有不规则道路图的古老城市,你需要使用与具有规则道路网格图案的现代城市不同的策略从A点到B点。在现代城市中,沿着指向目的地的道路行驶就足够了,但在古老城市中导航要复杂得多。朝向目的地前进往往会导致死胡同(欺骗性局部最优)。你需要采用更探索性的方法,尝试新颖且往往反直觉的方向,这些方向似乎会将你引离目的地。所以,最终,在道路的又一转弯后,你到达了目的地。然而,请注意,仅根据到达最终目的地的距离(即目标导向的适应度分数)来决定转弯并不明显。通向最终解决方案的垫脚石往往放置在看似将你引离的地方,但最终帮助你成功。

请参阅第1章神经进化方法概述,以获取更多关于NS优化的详细信息。

NS实现基础

NS实现应包括用于存储关于已探索新颖项的信息的数据结构,以及用于维护和管理新颖项列表的结构。在我们的实现中,此功能封装在三个Python类中:

  • NoveltyItem:一个结构,用于存储在进化过程中评估的个体的所有相关信息。

  • NoveltyArchive:一个类,用于维护相关NoveltyItem实例的列表。它提供了评估与已收集的NoveltyItem实例和当前种群相比的个体基因组新颖性分数的方法。

  • ItemsDistance:一个辅助结构,用于存储两个NoveltyItem实例之间的距离(新颖性)度量值。它在计算*均k最*邻距离时使用,该距离用作实验中的新颖性分数值。

对于实现细节,请参考 https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter6/novelty_archive.py 文件。

NoveltyItem

这个类是主要结构,用于存储在进化过程中评估的每个个体的新颖度分数信息。它有几个字段用于存储相关信息,正如我们在源代码中所看到的:

    def __init__(self, generation=-1, genomeId=-1, fitness=-1, novelty=-1):
        self.generation = generation
        self.genomeId = genomeId
        self.fitness = fitness
        self.novelty = novelty
        self.in_archive = False
        self.data = []

generation 字段持有创建此项时的代数 ID。基本上,genomeId 是被评估的基因组的 ID,而 fitness 是被评估基因组的(目标导向的)适应度分数(接*迷宫出口的距离)。此外,novelty 是分配给被评估基因组的新颖度分数,正如我们在下一节中讨论的,而 data 是表示迷宫求解代理在模拟过程中访问的特定迷宫位置坐标的数据点列表。此数据列表用于估计当前新颖项与其他新颖项之间的距离。计算出的距离之后可以用来估计与特定新颖项相关联的新颖度分数。

NoveltyArchive

这个类维护了一个相关新颖项的列表,并提供方法来评估单个基因组以及整个基因组种群的新颖度分数。它在构造函数中定义了以下字段:

    def __init__(self, threshold, metric):
        self.novelty_metric = metric
        self.novelty_threshold = threshold
        self.novelty_floor = 0.25
        self.items_added_in_generation = 0
        self.time_out = 0
        self.neighbors = KNNNoveltyScore
        self.generation = 0
        self.novel_items = []
        self.fittest_items = []

注意,novelty_metric 是一个指向函数的引用,该函数可以用来估计新颖度度量或两个新颖项之间的距离。

此外,novelty_threshold 定义了当前 NoveltyItem 需要达到的最小新颖度分数值,才能被添加到这个档案中。这个值是动态的,在执行过程中会改变,以保持档案的大小在特定的限制范围内;novelty_floornovelty_threshold 的最小可能值。items_added_in_generationtime_out 字段用于安排 novelty_threshold 值变化的动态性。neighbors 字段是用于新颖度分数估计的默认的 k-最*邻 数量。generation 是当前的进化代数。基本上,novel_items 是到目前为止收集到的所有相关 NoveltyItem 实例的列表,而 fittest_items 是在所有新颖项中具有最大目标导向适应度分数的新颖项列表。

novelty_threshold 字段的动态性由以下源代码确定:

    def _adjust_archive_settings(self):
        if self.items_added_in_generation == 0:
            self.time_out += 1
        else:
            self.time_out = 0
        if self.time_out >= 10:
            self.novelty_threshold *= 0.95
            if self.novelty_threshold < self.novelty_floor:
                self.novelty_threshold = self.novelty_floor
            self.time_out = 0
        if self.items_added_in_generation >= 4:
            self.novelty_threshold *= 1.2
        self.items_added_in_generation = 0

在每个进化代结束时调用前面的函数来调整下一代的novelty_threshold字段值。如前所述,此值决定了下一代应添加多少创新项目到存档中。随着时间的推移,使用NS方法找到新颖解决方案的难度动态调整此属性是必要的。在进化的开始,由于在迷宫中只探索了少数路径,因此找到具有高创新度分数的新颖解决方案的机会巨大。然而,在进化的后期,由于剩余未探索的路径较少,这变得更为困难。为了补偿这一点,如果在最后2,500次评估(10代)中没有找到新颖路径,则将novelty_threshold值降低5%。另一方面,为了在进化的早期阶段降低将新的NoveltyItem添加到存档的速度,如果在上一代中添加了超过四个项目,则将novelty_threshold值提高20%。

以下源代码显示了如何使用novelty_threshold值来确定要添加哪个NoveltyItem

    def evaluate_individual_novelty(self, genome, genomes, n_items_map, 
                                    only_fitness=False):
        item = n_items_map[genome.key]
        result = 0.0
        if only_fitness:
            result = self._novelty_avg_knn(item=item, genomes=genomes, 
                                           n_items_map=n_items_map)
        else:
            result = self._novelty_avg_knn(item=item, neighbors=1, 
                                           n_items_map=n_items_map)
            if result > self.novelty_threshold or \
               len(self.novel_items) < ArchiveSeedAmount:
                self._add_novelty_item(item)
        item.novelty = result
        item.generation = self.generation
        return result

前面的代码使用一个函数来评估创新度分数,我们将在下一节中描述该函数,以估计提供的基因组的新颖性。如果在此更新存档模式(only_fitness = False)下调用此函数,则获得的创新度分数(result)与当前novelty_threshold字段的值进行比较。根据比较结果,将NoveltyItem对象添加到NoveltyArchive对象中或不添加。此外,引入了ArchiveSeedAmount常量,在进化开始时存档仍然为空时,使用NoveltyItem实例对存档进行初始播种。

具有创新度分数的适应度函数

现在我们已经定义了NS方法背后的基本原理,我们需要找到一种方法将其整合到将用于指导神经进化过程的适应度函数定义中。换句话说,我们需要定义一个创新度指标,可以捕捉特定解算器代理在进化过程中引入的创新量。对于解算器代理,可以使用以下几种特征作为创新度指标:

  • 解算器基因型结构的创新性——结构创新性

  • 在解决方案搜索空间中找到的垫脚石——行为创新性

我们在本章中的主要兴趣是创建一个成功的迷宫导航代理。为了成功地在迷宫中导航,代理必须对迷宫中的大多数地方给予同等关注。这种行为可以通过奖励选择与之前测试代理已知路径相比独特探索路径的代理来实现。就之前提到的创新度指标类型而言,这意味着我们需要定义一个使用围绕行为创新性构建的适应度函数。

新颖分数

迷宫求解代理的行为空间由其在迷宫求解模拟中运行的轨迹定义。有效的创新分数实现需要在行为空间的任何点计算稀疏性。因此,任何具有更密集的行为空间访问点簇的区域都较少创新,给予求解代理更少的奖励。

第 1 章 中所述,神经进化方法概述,一个点稀疏性的最直接度量是从它到 k 个最*邻 的*均距离。稀疏区域具有更高的距离值,而密集区域具有更低的距离值,相应地。以下公式给出了行为空间中点 行为空间点 的稀疏性:

图片

注意 注意 是根据距离(新颖性)度量 距离度量 计算的 最*邻 最*邻 的最*邻。

根据上述公式计算的行为空间特定点的稀疏性是一个可以由适应度函数使用的创新分数。

查找新颖分数的 Python 代码定义在以下函数中:

    def _novelty_avg_knn(self, item, n_items_map, genomes=None, 
                         neighbors=None):
        distances = None
        if genomes is not None:
            distances = self._map_novelty_in_population(item=item, 
                          genomes=genomes, n_items_map=n_items_map)
        else:
            distances = self._map_novelty(item=item)
        distances.sort()
        if neighbors is None:
            neighbors = self.neighbors

        density, weight, distance_sum = 0.0, 0.0, 0.0
        length = len(distances)
        if length >= ArchiveSeedAmount:
            length = neighbors
            if len(distances) < length:
                length = len(distances)
            i = 0
            while weight < float(neighbors) and i < length:
                distance_sum += distances[i].distance
                weight += 1.0
                i += 1
            if weight > 0:
                sparsity = distance_sum / weight
        return sparsity

上述函数的主要实现部分如下:

  1. 首先,我们检查提供的 _novelty_avg_knn 函数的参数是否包含当前种群中所有基因组的列表。如果是这样,我们就开始填充种群中所有基因组的特征行为之间的距离列表,包括来自 NoveltyArchive 的所有 NoveltyItem 对象。否则,我们使用提供的新颖性项(item)来找到它与 NoveltyArchive 中所有 NoveltyItem 对象之间的距离。
    distances = None
    if genomes is not None:
        distances = self._map_novelty_in_population(item=item, 
                         genomes=genomes, n_items_map=n_items_map)
    else:
        distances = self._map_novelty(item=item)
  1. 然后,我们将距离列表按升序排序,以便首先得到最小的距离,因为我们对在行为空间中与提供的创新项最接*的点感兴趣:
    distances.sort()
  1. 接下来,我们初始化计算 k 个最*邻分数所必需的所有中间变量,并测试在之前步骤中收集的距离值数量是否高于 ArchiveSeedAmount 常量值:
    if neighbors is None:
        neighbors = self.neighbors

    density, weight, distance_sum = 0.0, 0.0, 0.0
    length = len(distances)
  1. 现在,我们可以检查找到的距离列表的长度是否小于我们要求测试的邻居数量(neighbors)。如果是这样,我们更新相关变量的值:
    if length >= ArchiveSeedAmount:
        length = neighbors
        if len(distances) < length:
            length = len(distances)
  1. 在所有局部变量都设置为正确值之后,我们可以开始收集每个连接的所有距离和权重的总和的循环:
        i = 0
        while weight < float(neighbors) and i < length:
            distance_sum += distances[i].distance
            weight += 1.0
            i += 1
  1. 当先前的循环由于计算出的权重值超过指定的邻居数量而退出,或者如果我们已经迭代过distances列表中的所有距离值,我们就准备好计算给定项目的新颖性得分,作为到k个最*邻的*均距离:
        if weight < 0:
            sparsity = distance_sum / weight 

函数随后返回估计的新颖性得分值。

对于更多实现细节,请参阅https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter6/novelty_archive.py文件。

新颖性度量

新颖性度量是衡量当前解决方案与已知解决方案之间差异的指标。它用于在估计行为空间中当前点到其k个最*邻的距离时计算新颖性得分。

在我们的实验中,测量两个代理行为差异的新颖性度量是通过两个轨迹向量之间的项距离(每个代理一个向量)确定的。轨迹向量包含迷宫导航代理在模拟期间访问的位置坐标。以下公式给出了该度量的定义:

![img/a61b41a8-e092-4cca-8df7-32742826e827.png]

注意 ![img/cbb7ac77-245c-4bb4-9e7d-415935eedb59.png] 是轨迹向量的大小,而 ![img/ead12303-fc2d-4a5d-9a5c-10b6fa003efc.png] 和 ![img/e8429ede-3355-4312-9e8c-8485763b8fda.png] 是比较轨迹向量 ![img/15e07257-bf42-4ea2-ab1a-83055f6410b7.png] 和 ![img/dacf8b1f-2342-42a5-903b-1a8168bcd0a6.png] 在位置 ![img/15e07257-bf42-4ea2-ab1a-83055f6410b7.png] 的值。

在迷宫导航实验中,我们主要对求解代理的最终位置感兴趣。因此,轨迹向量可能仅包含代理在完成迷宫导航模拟中所有必要步骤后的最终坐标,或者在找到迷宫出口时。

以下是对新颖性度量值估计的Python代码:

def maze_novelty_metric(first_item, second_item):
    diff_accum = 0.0
    size = len(first_item.data)
    for i in range(size):
        diff = abs(first_item.data[i] - second_item.data[i])
        diff_accum += diff

    return diff_accum / float(size)

上述代码取两个新颖性项,并找到在迷宫导航模拟中持有相应求解代理位置的两个轨迹向量之间的项距离

适应度函数

本章所述实验中使用的适应度函数直接将先前定义的新颖性得分作为基因的适应度值。因此,神经进化过程试图通过使用这种适应度函数来最大化产生个体的新颖性。

在本实验的不同任务中,我们使用各种适应度因素:

  • 新颖性得分用于指导神经进化过程(解决方案搜索优化)。它被分配为每个基因的适应度值,并在进化的各个世代中用于基因评估。

  • 从迷宫模拟器获得的目标导向适应度分数(到达迷宫出口的距离)用于测试是否实现了最终目标(即找到了迷宫出口)——此外,此值也记录下来以评估每个求解代理的性能。

健身值评估的源代码包含两个函数:

  • 评估整个种群适应度分数的回调函数(eval_genomes

  • 通过迷宫解决模拟评估单个基因的函数(eval_individual

种群适应度评估函数

适应度评估函数是一个回调函数,它注册到NEAT-Python库中,允许该库运行对种群基因进行评估,以解决特定任务的特定条件。我们实现此函数以使用迷宫解决任务评估当前种群中的每个基因,并使用获得的新颖性分数作为基因的适应度值。

NEAT-Python库不允许我们从回调函数发送任何关于任务完成的信号,除了指定获胜基因的特定适应度分数值。这个适应度值必须高于NEAT-Python超参数配置中的适应度阈值。然而,使用NS算法,无法准确估计获胜基因可以实现的新颖性分数的上限。此外,获胜基因的新颖性分数值可能低于在进化过程中,当解决方案搜索空间没有如此彻底探索时,早期获得的基因的值。

因此,鉴于新颖性分数被分配给基因作为它们的适应度值,我们需要想出一个解决方案,使我们能够使用NEAT-Python库定义的标准终止标准。我们通过使用一个足够大的特定指示性新颖性分数值来实现这一点,这个值在正常算法执行过程中可能会遇到。这个值决定了通过NEAT-Python超参数配置提供的终止标准。我们将800000用作新颖性分数及其自然对数(约13.59)作为适当的适应度阈值。

函数的完整源代码如下:

def eval_genomes(genomes, config):
    n_items_map = {}
    solver_genome = None
    for genome_id, genome in genomes:
        found = eval_individual(genome_id=genome_id, 
                                genome=genome, 
                                genomes=genomes, 
                                n_items_map=n_items_map, 
                                config=config)
        if found:
            solver_genome = genome
    trial_sim.archive.end_of_generation()
    # Now evaluate fitness of each genome in population
    for genome_id, genome in genomes:
        fitness = trial_sim.archive.evaluate_individual_novelty(
                   genome=genome,
                   genomes=genomes,
                   n_items_map=n_items_map,
                   only_fitness=True)
        if fitness > 1:
            fitness = math.log(fitness)
        else:
            fitness = 0
        genome.fitness = fitness

    if solver_genome is not None:
        solver_genome.fitness = math.log(800000) # ~=13.59

函数实现的显著部分如下:

  1. 首先,我们创建字典以存储评估后的新颖性项目(n_items_map),用于种群中每个基因,并遍历种群中的所有基因,评估它们的迷宫解决性能:
    n_items_map = {}
    solver_genome = None
    for genome_id, genome in genomes:
        found = eval_individual(genome_id=genome_id, 
                                genome=genome, 
                                genomes=genomes, 
                                n_items_map=n_items_map, 
                                config=config)
        if found:
            solver_genome = genome
    trial_sim.archive.end_of_generation()
  1. 之后,我们再次遍历种群中的所有基因,使用估计的新颖性分数为基因分配适应度分数。新颖性分数估计的过程使用在第一次循环(如前所述)中收集的NoveltyItem对象,在迷宫解决模拟期间:
    for genome_id, genome in genomes:
        fitness = trial_sim.archive.evaluate_individual_novelty(
                   genome=genome,
                   genomes=genomes,
                   n_items_map=n_items_map,
                   only_fitness=True)
        if fitness > 1:
            fitness = math.log(fitness)
        else:
            fitness = 0
        genome.fitness = fitness
  1. 最后,如果在第一个循环中找到成功的解决基因组,我们将其分配一个等于前面描述的指示性适应度分数的适应度值(~13.59):
    if solver_genome is not None:
        solver_genome.fitness = math.log(800000) # ~13.59

请注意,我们将获得的创新度分数值和指示性创新度分数应用自然对数,以保持它们在数值上的接*。因此,我们可以使用实验期间收集的统计数据正确地绘制性能图表。

个体适应度评估函数

这个函数是种群适应度评估的重要组成部分,它通过前面讨论过的eval_genomes函数被调用,以评估种群中每个基因组的迷宫解决性能。

通过迷宫导航模拟评估个体基因组作为迷宫解决代理的情况如下:

def eval_individual(genome_id, genome, genomes, n_items_map, config):
    n_item = archive.NoveltyItem(
                        generation=trial_sim.population.generation,
                        genomeId=genome_id)
    n_items_map[genome_id] = n_item
    maze_env = copy.deepcopy(trial_sim.orig_maze_environment)
    control_net = neat.nn.FeedForwardNetwork.create(genome, config)
    goal_fitness = maze.maze_simulation_evaluate(
                                    env=maze_env, 
                                    net=control_net, 
                                    time_steps=SOLVER_TIME_STEPS,
                                    n_item=n_item,
                                    mcns=MCNS)

    if goal_fitness == -1:
        # The individual doesn't meet the min. fitness criterion
        print("Individ with ID %d marked for extinction, MCNS %f" 
               % (genome_id, MCNS))
        return False

    record = agent.AgentRecord(
        generation=trial_sim.population.generation,
        agent_id=genome_id)
    record.fitness = goal_fitness
    record.x = maze_env.agent.location.x
    record.y = maze_env.agent.location.y
    record.hit_exit = maze_env.exit_found
    record.species_id = trial_sim.population.species \
        .get_species_id(genome_id)
    record.species_age = record.generation - \
       trial_sim.population.species.get_species(genome_id).created
    trial_sim.record_store.add_record(record)

    if not maze_env.exit_found:
        record.novelty = trial_sim.archive \
         .evaluate_individual_novelty(genome=genome, 
                       genomes=genomes, n_items_map=n_items_map)

    trial_sim.archive.update_fittest_with_genome(genome=genome, 
                                        n_items_map=n_items_map)
    return maze_env.exit_found

让我们深入探讨eval_individual函数实现中所有核心部分的意义:

  1. 首先,我们创建一个NoveltyItem对象来保存与特定基因组相关的创新度分数信息,并将其保存在n_items_map字典中的genome_id键下:
    n_item = archive.NoveltyItem(
                       generation=trial_sim.population.generation,
                       genomeId=genome_id)
    n_items_map[genome_id] = n_item
  1. 之后,我们创建原始迷宫环境的深度副本以避免模拟期间的副作用,并从提供的基因组创建控制ANN:
    maze_env = copy.deepcopy(trial_sim.orig_maze_environment)
    control_net = neat.nn.FeedForwardNetwork.create(genome, config)
  1. 现在,使用迷宫环境的副本和创建的控制ANN,我们执行给定步数的迷宫解决模拟:
    goal_fitness = maze.maze_simulation_evaluate(
                                    env=maze_env, 
                                    net=control_net, 
                                    time_steps=SOLVER_TIME_STEPS,
                                    n_item=n_item,
                                    mcns=MCNS)
  1. 模拟完成后,返回的基于目标的适应度分数(接*迷宫出口的距离)和其他模拟及基因组参数存储在AgentRecord中,然后将其添加到记录存储中:
    record = agent.AgentRecord(
        generation=trial_sim.population.generation,
        agent_id=genome_id)
    record.fitness = goal_fitness
    record.x = maze_env.agent.location.x
    record.y = maze_env.agent.location.y
    record.hit_exit = maze_env.exit_found
    record.species_id = trial_sim.population.species \
        .get_species_id(genome_id)
    record.species_age = record.generation - \
       trial_sim.population.species.get_species(genome_id).created
    trial_sim.record_store.add_record(record)
  1. 最后,如果给定的基因组不是赢家,我们估计其创新度分数,并在适当的情况下,使用当前基因组的NoveltyItem更新NoveltyArchive中适应度最高的基因组列表:
    if not maze_env.exit_found:
        record.novelty = trial_sim.archive \
         .evaluate_individual_novelty(genome=genome, 
              genomes=genomes, n_items_map=n_items_map)

    trial_sim.archive.update_fittest_with_genome(genome=genome, 
                                        n_items_map=n_items_map)

在这个实验中,基因组的适应度分数定义为两个不同的值,每个值都服务于不同的目的。目标导向的适应度分数有助于测试是否找到了解决方案并收集有用的性能统计数据。基于创新度的适应度分数引导神经进化过程朝着解决行为最大多样性的方向发展,这意味着解决方案搜索的梯度被引导去探索不同的行为,而不存在任何明确的目标。

关于实现的更多细节,请参阅https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter6/maze_experiment.py文件。

尝试简单的迷宫配置

我们使用与上一章中描述的类似的一个简单迷宫配置开始我们的实验。然而,我们不是使用以目标为导向的目标函数,而是使用NS优化方法来指导神经进化过程。我们希望使用新颖性搜索方法可以在更少的进化周期内找到成功的迷宫求解器。

你可以在以下图表中看到简单迷宫的架构:

图片

简单迷宫配置

迷宫配置与上一章相同。然而,我们需要调整相应的NEAT超参数以满足NS优化方法的要求。

超参数选择

本章所述实验中使用的目标函数基于一个没有明确上限值的创新度指标。因此,适应度阈值值无法精确估计。因此,为了表明找到了获胜的解决方案,我们使用一个足够大以至于在正常算法执行过程中不会遇到的指示值。

我们选择了800000作为指示性新颖度分数值。然而,为了在绘制实验结果时保持适应度分数的视觉表现,我们使用自然对数缩小了求解器代理获得的新颖度分数。因此,配置文件中使用的适应度阈值值变为13.5,略低于最大可能的适应度分数(13.59),以避免浮点数舍入的问题。此外,我们将种群大小从上一章中描述的值(250)增加到使解决方案搜索空间更深入,因为我们需要检查迷宫中最大数量的唯一位置:

[NEAT]
fitness_criterion = max
fitness_threshold = 13.5
pop_size = 500
reset_on_extinction = False

在每个试验中,我们运行的代数比上一章实验中多。因此,我们将停滞值增加,以使物种保持更长时间:

[DefaultStagnation]
max_stagnation = 100

所有其他NEAT超参数的值与上一章中展示的类似。请参考上一章了解选择特定超参数值的原因。

实验中使用的完整超参数列表可以在https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter6/maze_config.inimaze_config.ini文件中找到。

工作环境设置

实验的工作环境应包括所有依赖项,并可以使用以下命令使用Anaconda创建:

$ conda create --name maze_ns_neat python=3.5
$ conda activate maze_ns_neat
$ pip install neat-python==0.92 
$ conda install matplotlib
$ conda install graphviz
$ conda install python-graphviz

这些命令创建并激活了一个Python 3.5的maze_ns_neat虚拟环境。之后,安装了版本0.92的NEAT-Python库,以及我们可视化工具使用的其他依赖项。

实验运行器实现

本章中使用的实验运行函数在大多数方面与上一章中使用的函数相似,但具有显著的不同之处,我们将在本节中讨论。

试验周期

在本章中,我们介绍了对实验运行函数实现的升级。我们实现了支持顺序运行多个试验直到找到解决方案的功能。这种升级极大地简化了顺序处理多个实验试验的工作,特别是考虑到每个试验可能需要很长时间来执行。

实验运行函数的主循环现在看起来是这样的(参见maze_experiment.py脚本中的__main__):

    print("Starting the %s maze experiment (Novelty Search), for %d trials" 
          % (args.maze, args.trials))
    for t in range(args.trials):
        print("\n\n----- Starting Trial: %d ------" % (t))
        # Create novelty archive
        novelty_archive = archive.NoveltyArchive(
                                  threshold=args.ns_threshold,
                                  metric=maze.maze_novelty_metric)
        trial_out_dir = os.path.join(out_dir, str(t))
        os.makedirs(trial_out_dir, exist_ok=True)
        solution_found = run_experiment( config_file=config_path, 
                                        maze_env=maze_env, 
                                        novelty_archive=novelty_archive,
                                        trial_out_dir=trial_out_dir,
                                        n_generations=args.generations,
                                        args=args,
                                        save_results=True,
                                        silent=True)
        print("\n------ Trial %d complete, solution found: %s ------\n" 
               % (t, solution_found))

循环运行args.trials数量的实验试验,其中args.trials是由用户从命令行提供的。

循环的前几行创建了一个NoveltyArchive对象,它是新颖性搜索算法的一部分。在特定的试验中,此对象将用于存储所有相关的NoveltyItems

        novelty_archive = archive.NoveltyArchive(
                       threshold=args.ns_threshold,
                       metric=maze.maze_novelty_metric)

注意,maze.maze_novelty_metric是对用于评估每个求解代理新颖度分数的函数的引用。

在本章的源代码中,我们提供了两个新颖性度量函数的实现:

  • 逐项距离新颖性度量(maze.maze_novelty_metric

  • 欧几里得距离新颖性度量(maze.maze_novelty_metric_euclidean

然而,在我们的实验中,我们使用第一种实现。第二种实现是为了让你运行额外的实验。

实验运行函数

运行函数与上一章中介绍的运行函数有许多相似之处,但同时也具有特定于NS优化算法的独特功能。

在这里,我们考虑实现中的最显著部分:

  1. 它从为随机数生成器选择一个特定的种子值开始,这个种子值基于当前系统时间:
    seed = int(time.time())
    random.seed(seed)
  1. 之后,它加载NEAT算法配置并创建一个初始基因组种群:
config = neat.Config(neat.DefaultGenome, 
                     neat.DefaultReproduction, 
                     neat.DefaultSpeciesSet, 
                     neat.DefaultStagnation, 
                     config_file)
p = neat.Population(config) 
  1. 为了在每代评估后保存中间结果,我们使用MazeSimulationTrial对象初始化一个名为trial_sim的全局变量。

我们使用一个全局变量,以便它可以通过传递给NEAT-Python框架的适应度评估回调函数(eval_genomes(genomes, config))进行访问:

    global trial_sim
    trial_sim = MazeSimulationTrial(maze_env=maze_env, 
                                    population=p,
                                    archive=novelty_archive)
  1. 此外,传统上,我们通过Population对象注册报告算法结果和收集统计信息的报告者数量:
    p.add_reporter(neat.StdOutReporter(True))
    stats = neat.StatisticsReporter()
    p.add_reporter(stats)
  1. 现在,我们已经准备好在指定数量的代数上运行NEAT算法并评估结果:
    start_time = time.time()
    best_genome = p.run(eval_genomes, n=n_generations)
    elapsed_time = time.time() - start_time
    # Display the best genome among generations.
    print('\nBest genome:\n%s' % (best_genome))
    solution_found = \
        (best_genome.fitness >= config.fitness_threshold)
    if solution_found:
        print("SUCCESS: The stable maze solver controller was found!!!")
    else:
        print("FAILURE: Failed to find the stable maze solver controller!!!")
  1. 之后,收集的统计数据和新颖性存档记录可以被可视化并保存到文件系统:
    node_names = {-1:'RF_R', -2:'RF_FR', -3:'RF_F', -4:'RF_FL', 
                    -5:'RF_L', -6: 'RF_B', -7:'RAD_F', -8:'RAD_L',
                    -9:'RAD_B', -10:'RAD_R', 0:'ANG_VEL', 1:'VEL'}
    visualize.draw_net(config, best_genome, view=show_results, 
                           node_names=node_names, 
                           directory=trial_out_dir, fmt='svg')
    if args is None:
        visualize.draw_maze_records(maze_env, 
                                trial_sim.record_store.records,
                                view=show_results)
    else:
        visualize.draw_maze_records(maze_env, 
                           trial_sim.record_store.records, 
                           view=show_results, width=args.width, 
                           height=args.height,
                           filename=os.path.join(trial_out_dir, 
                                           'maze_records.svg'))
    visualize.plot_stats(stats, ylog=False, 
                          view=show_results,
                          filename=os.path.join(trial_out_dir, 
                                           'avg_fitness.svg'))
    visualize.plot_species(stats, view=show_results, 
                          filename=os.path.join(trial_out_dir, 
                                            'speciation.svg'))
    # store NoveltyItems archive data
    trial_sim.archive.write_fittest_to_file(
                             path=os.path.join(trial_out_dir, 
                                     'ns_items_fittest.txt'))
    trial_sim.archive.write_to_file(
                             path=os.path.join(trial_out_dir, 
                                         'ns_items_all.txt'))
  1. 最后,我们执行本章中介绍的其他可视化程序,这些程序可视化迷宫求解代理在迷宫中的路径。

我们通过运行迷宫导航模拟,与在进化过程中找到的最佳求解代理的控制器ANN进行对比来实现这一点。在这次模拟运行期间,所有由求解代理访问的路径点都被收集起来,稍后由draw_agent_path函数进行渲染:

    maze_env = copy.deepcopy(trial_sim.orig_maze_environment)
    control_net = neat.nn.FeedForwardNetwork.create(
                                            best_genome, config)
    path_points = []
    evaluate_fitness = maze.maze_simulation_evaluate(
                                    env=maze_env, 
                                    net=control_net, 
                                    time_steps=SOLVER_TIME_STEPS,
                                    path_points=path_points)
    print("Evaluated fitness of best agent: %f" 
              % evaluate_fitness)
    visualize.draw_agent_path(trial_sim.orig_maze_environment, 
                             path_points, best_genome,
                             view=show_results, 
                             width=args.width,
                             height=args.height,
                             filename=os.path.join(trial_out_dir,
                                        'best_solver_path.svg'))

最后,run_experiment函数返回一个布尔值,指示在试验期间是否找到了成功的迷宫求解代理。

请参考位于https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter6/maze_experiment.pymaze_experiment.py文件中的run_experiment(config_file, maze_env, novelty_archive, trial_out_dir, args=None, n_generations=100, save_results=False, silent=False)函数。

使用NS优化运行简单的迷宫导航实验

确保将所有相关的Python脚本和配置文件(maze_config.inimedium_maze.txt)从在线仓库复制到本地目录,该仓库位于:https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter6/

现在进入这个目录,并在终端应用程序中执行以下命令:

python maze_experiment.py -g 500 -t 10 -m medium --width 300 --height 150

不要忘记使用以下命令激活适当的虚拟环境:

conda activate maze_ns_neat

上述命令运行了10次迷宫导航实验,加载了从medium_maze.txt文件加载的简单迷宫配置。神经进化算法在每个试验中评估迷宫求解者的500代,使用从maze_config.ini文件加载的NEAT配置数据。widthheight参数指定了迷宫记录子图的尺寸(有关更多详细信息,请参阅visualize.draw_maze_records函数实现)。

在进化99代之后,成功迷宫求解代理在第100代被发现。在进化的最后一代中,关于基因组种群的一般统计数据。在完成Python程序的控制台输出中,您将看到以下内容:

 ****** Running generation 100 ****** 

Maze solved in 391 steps
Population's average fitness: 1.28484 stdev: 0.90091
Best fitness: 13.59237 - size: (2, 8) - species 1 - id 48354

Best individual in generation 100 meets fitness threshold - complexity: (2, 8)

之后,我们显示获胜基因组的配置和关于试验的一般统计数据:

Best genome:
Key: 48354
Fitness: 13.592367006650065
Nodes:
 0 DefaultNodeGene(key=0, bias=-2.1711339938349026, response=1.0, activation=sigmoid, aggregation=sum)
 1 DefaultNodeGene(key=1, bias=6.576480565646596, response=1.0, activation=sigmoid, aggregation=sum)
Connections:
 DefaultConnectionGene(key=(-10, 1), weight=-0.5207773885939109, enabled=True)
 DefaultConnectionGene(key=(-9, 0), weight=1.7778928210387814, enabled=True)
 DefaultConnectionGene(key=(-7, 1), weight=-2.4940590667086524, enabled=False)
 DefaultConnectionGene(key=(-6, 1), weight=-1.3708732457648565, enabled=True)
 DefaultConnectionGene(key=(-4, 0), weight=4.482428082179011, enabled=True)
 DefaultConnectionGene(key=(-4, 1), weight=-1.3103728328721098, enabled=True)
 DefaultConnectionGene(key=(-3, 0), weight=-0.4583080031587811, enabled=True)
 DefaultConnectionGene(key=(-3, 1), weight=4.643599450804774, enabled=True)
 DefaultConnectionGene(key=(-2, 1), weight=-0.9055329546235956, enabled=True)
 DefaultConnectionGene(key=(-1, 0), weight=-1.5899992185951817, enabled=False)
SUCCESS: The stable maze solver controller was found!!!
Record store file: out/maze_ns/medium/0/data.pickle
Random seed: 1567086899
Trial elapsed time: 7452.462 sec
Plot figure width: 6.8, height: 7.0
Maze solved in 391 steps
Evaluated fitness of best agent: 1.000000
Plot figure width: 7.8, height: 4.0

控制台输出显示,编码成功迷宫求解者控制ANN的获胜基因组只有两个节点基因和八个连接基因。这些基因对应于控制器ANN中的两个输出节点,八个连接用于与输入建立联系。控制器ANN的结果配置如下所示:

图片

成功控制器ANN的配置

成功控制ANN的配置优于上一章中描述的配置,后者是通过目标导向搜索优化方法找到的。在这个实验中,ANN配置完全省略了隐藏节点,进化过程需要更少的代数就能找到它。

因此,我们可以假设新颖性搜索优化方法至少与目标导向方法一样有效。尽管搜索优化方法不是基于对最终目标的接*度,而是基于奖励新颖行为,但这一点仍然成立。神经进化过程产生了一个成功的迷宫求解代理,没有任何关于最终目标(迷宫出口)的提示,这真是太令人惊讶了。

此外,观察进化过程中的物种分化图也很有趣:

图片

物种分化图

在物种分化图中,我们可以看到在进化过程中物种的总数不超过九种。此外,其中大多数物种从进化的第一代开始就存在,直到找到成功的迷宫求解者。

代理记录可视化

我们使用了上一章中介绍的可视化代理记录的方法,并引入了一种新的可视化方法来展示求解代理在迷宫中的路径。

对代理记录的可视化以obj_medium_maze_records.svg SVG文件的形式自动保存在对应实验的输出目录中。

在下面的图像中,您可以查看本章所述实验的代理记录可视化:

图片

对代理记录的可视化

图表的顶部子图显示了具有目标导向适应度得分值高于0.8的最适合物种的代理的最终位置。我们找到了八种物种,它们几乎探索了迷宫的所有区域,并最终找到了迷宫出口。同时,即使是进化失败者(底部图)也表现出高度探索性的行为,均匀地填充了迷宫区域的前半部分(与上一章中的类似图表进行比较)。

此外,值得注意的是,在进化过程中创建的九种物种中有八种表现出最高的目标导向适应度得分;也就是说,它们几乎能够到达迷宫出口(其中一种最终到达了)。这一成就与上一章的实验形成鲜明对比,在上一章的实验中,只有一半的物种(十二种中的六种)达到了相同的结果。

然而,最令人兴奋的可视化允许我们查看能够找到迷宫出口的成功迷宫求解代理的路径:

图片

成功迷宫求解者通过迷宫的路径

可视化结果可以在实验的output目录下的best_solver_path.svg文件中找到。

如你所见,一个成功的迷宫求解代理能够找到迷宫中的几乎最优路径,尽管它在开始时似乎有些困惑。

真是令人难以置信,这样的迷宫路径可以在没有任何关于迷宫出口位置参考的情况下找到,仅仅是通过奖励每个找到的中间解决方案的新颖性。

练习1

  1. maze_config.ini文件中的pop_size(种群大小)参数设置为250。看看在这种情况下是否可以找到迷宫求解器。

  2. 修改指定添加新节点概率的参数值(node_add_prob)。神经进化过程是否找到了解决方案,并且从拓扑角度来看是否是最优的?

  3. 将初始基因组配置修改为零隐藏节点(num_hidden)。这如何影响算法的性能?

  4. 尝试使用源代码中提供的另一个新颖性度量指标(maze.maze_novelty_metric_euclidean)并看看会发生什么。

  5. 将命令行参数location_sample_rate从默认值(4000)修改,这允许你只将迷宫求解器的最终位置包含到其行为向量中。尝试小于400(迷宫模拟步骤数)的值。例如,如果我们设置此参数为100,那么行为向量将包括每个求解代理最多四个轨迹点的坐标。看看这个参数如何影响算法性能。你可以通过运行以下命令来提供此参数的值:

python maze_experiment.py -g 500 -t 10 -r 100 -m medium --width 300 --height 150

上述命令以location_sample_rate设置为100运行简单的迷宫实验。

在难以解决的迷宫配置上进行实验

在下一个实验中,我们评估NS优化方法在更复杂任务中的有效性。在这个任务中,我们尝试进化一个迷宫求解代理,使其能够找到复杂配置的迷宫路径。

对于这个实验,我们使用上一章中引入的难以解决的迷宫配置。这种方法允许我们比较使用NS优化方法获得的结果与上一章中使用的目标导向优化方法获得的结果。迷宫配置如下:

图片

难以解决的迷宫配置

这个迷宫配置与上一章中描述的配置相同。因此,你可以参考第5章自主迷宫导航,以获取详细描述。

超参数选择和工作环境设置

本实验的超参数与我们在本章前面进行的简单迷宫实验中使用的相同。我们决定保持超参数不变,以测试算法通过尝试在同一领域内找到解决方案的能力,但具有不同的配置来测试算法的泛化能力。

本实验的工作环境与为简单迷宫实验创建的环境完全兼容。因此,我们也可以使用它。

运行难以解决的迷宫导航实验

要运行此实验,我们可以使用为简单迷宫实验开发的相同实验运行器,唯一的区别是在启动时应该提供不同的命令行参数。你可以使用以下命令启动困难迷宫实验:

$ python maze_experiment.py -m hard -g 500 -t 10 --width 200 --height 200

此命令启动了10次试验的难以解决的迷宫实验,每次试验有500代。宽度和高度参数决定了绘制实验期间收集到的迷宫记录的子图的尺寸。

在进行困难迷宫实验时,我们使用NEAT-Python库,在10次试验中未能找到成功的迷宫求解代理,即使使用了NS优化方法。尽管如此,使用NS方法获得的结果比上一章中的目标导向优化方法更有希望。你可以在以下图表中看到这一点,该图表描绘了迷宫导航模拟期间求解代理的最终位置:

图片

代理记录的可视化

可视化所有评估代理最终位置的图表表明,在这次实验中,使用NS优化方法探索的迷宫区域比使用目标导向方法更多。你还可以看到,某些物种几乎到达终点线,只需几步就能到达迷宫出口。

最成功迷宫求解代理的路径如下:

图片

通过最成功迷宫求解代理的迷宫路径

最成功求解代理在迷宫中走过的路径表明,代理能够发现传感器输入与执行动作之间的关键关系。然而,在应用控制信号方面仍缺乏精确性。由于这个缺陷,一些控制动作导致无效的轨迹循环,消耗了宝贵的解决迷宫的时间步数。

最后,看看最成功迷宫求解代理的控制ANN拓扑结构是很有趣的:

图片

控制ANN的拓扑结构

你可以看到,所有传感器输入都参与了决策,这与本章前一个实验中设计的控制ANN拓扑结构形成对比。此外,网络拓扑包括两个隐藏节点,这使得代理能够实现一个复杂控制策略,以导航通过难以解决的迷宫环境。

尽管在这次实验中,我们未能使用NEAT-Python库通过新颖性搜索优化方法进化出一个成功的迷宫求解代理,但这更多是库中NEAT实现无效的问题,而不是新颖性搜索方法的失败。

我使用GO编程语言实现了一个NEAT算法,该算法以高效率解决了一个困难的迷宫导航任务。你可以在GitHub上查看https://github.com/yaricom/goNEAT_NS

练习2

在本章的源代码中,我们还提供了基于我们在第2章中介绍的MultiNEAT Python库的实验运行器实现,Python库和环境设置

你可以尝试如下使用它来解决困难迷宫任务:

  1. 使用以下命令更新当前Anaconda环境,安装MultiNEAT Python库:
$ conda install -c conda-forge multineat
  1. 基于MultiNEAT库运行实验运行器实现:
$ python maze_experiment_multineat.py -m hard -g 500 -t 10 --width 200 --height 200

这些命令在当前Anaconda环境中安装MultiNEAT库,并使用适当的实验运行器启动10次(每次500代)的困难迷宫实验。

摘要

在本章中,你了解了新颖性搜索优化方法以及它如何用于指导在欺骗性问题空间环境中的神经进化过程,例如迷宫导航。我们进行了与上一章相同的迷宫导航实验。之后,我们比较了获得的结果,以确定NS方法是否优于上一章中介绍的目标导向优化方法。

你通过使用Python编写源代码获得了实际经验,并尝试调整NEAT算法的重要超参数。此外,我们引入了一种新的可视化方法,让你能够看到代理在迷宫中的路径。使用这种方法,你可以轻松地比较不同代理尝试解决迷宫导航问题的方法,以及找到的迷宫路径是否最优。

下一章将介绍NEAT算法的更多高级应用。我们首先介绍视觉识别任务,并介绍NEAT算法的HyperNEAT扩展。HyperNEAT方法允许你处理大规模的ANN,这些ANN操作在数千或数百万个参数上。这种规模的运算对于经典的NEAT算法是不可能的。

第九章:第3节:高级神经进化方法

本节讨论了高级神经进化方法及其如何用于解决实际问题。你将了解高级神经进化技术,并找到新项目的灵感。

本节包含以下章节:

  • 第7章基于超立方体的NEAT进行视觉区分

  • 第8章ES-HyperNEAT与视网膜问题

  • 第9章协同进化与SAFE方法

  • 第10章深度神经进化

第十章:基于超立方的NEAT用于视觉辨别

在本章中,你将了解基于超立方体的NEAT算法背后的主要概念以及它旨在解决的主要挑战。我们将探讨在尝试使用直接基因组编码与大规模人工神经网络ANN)时出现的问题,以及如何通过引入间接基因组编码方案来解决这些问题。你将学习如何使用组合模式生成网络(CPPN)以极高的压缩率存储基因组编码信息,以及HyperNEAT算法如何使用CPPNs。最后,你将通过实际示例了解HyperNEAT算法的强大功能。

在本章中,我们将讨论以下主题:

  • 使用NEAT直接编码大规模自然网络的问题,以及HyperNEAT如何通过引入间接编码方法来帮助解决这个问题

  • 使用NEAT进化CPPN以探索超立方体中的几何规律,这使我们能够高效地编码目标ANN中的连接模式

  • 如何使用HyperNEAT方法在视觉场中检测和识别对象

  • 视觉辨别实验的目标函数定义

  • 讨论视觉辨别实验结果

技术要求

为了执行本章中描述的实验,以下技术要求应得到满足:

  • Windows 8/10,macOS 10.13或更高版本,现代Linux

  • Anaconda Distribution版本2019.03或更高版本

本章的代码可以在https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/tree/master/Chapter7找到

使用CPPNs间接编码ANN

在前几章中,你学习了使用自然启发式的基因型概念直接编码人工神经网络(ANN),该基因型以1:1的比例映射到表型以表示ANN拓扑结构。这种映射使我们能够使用先进的NEAT算法特性,如创新编号,它允许我们在进化过程中跟踪特定突变何时被引入。基因组中的每个基因都有一个特定的创新编号值,这使得快速准确地交叉父代基因组以产生后代成为可能。虽然这一特性带来了巨大的好处并减少了在重组过程中匹配父代基因组所需的计算成本,但用于编码表型ANN拓扑结构的直接编码方法有一个显著的缺点,即它限制了编码ANN的大小。编码的ANN越大,在进化过程中评估的基因组就越大,这涉及到巨大的计算成本。

有许多任务,主要与图像或其他高维数据源中的模式识别相关,需要使用具有许多层和节点的高级拓扑结构的ANN。由于之前讨论的直接编码的低效性,这种拓扑配置不能被经典的NEAT算法有效地处理。

为了解决这一缺点,同时保留NEAT算法提供的所有优点,提出了编码表型ANN的新方法。我们将在下一节中讨论它。

CPPN编码

提出的编码方案采用了一种通过查询另一个专门神经网络来表示表型ANN中连接模式的方法,该专门神经网络关于节点之间连接的权重。这个专门神经网络被称为CPPN。其主要任务是将其几何作为函数来表示表型ANN的连接模式。生成的连接模式表示为一个四维超立方体。超立方体的每个点编码了表型ANN中两个相关节点之间的连接,并由四个数字描述:源节点的坐标和目标节点的坐标。连接性CPPN将超立方体的每个点作为输入,并计算表型ANN中每个节点之间的连接权重。此外,如果CPPN返回的连接权重的大小小于一个最小阈值(图片),则两个节点之间的连接不会表示。因此,我们可以将连接性CPPN定义为返回连接权重的四维函数,如下公式所示:

图片

表型ANN的源节点位于图片,目标节点位于图片

CPPN的另一个基本特征是,与仅使用一种激活函数(通常来自Sigmoid函数族)的常规ANN不同,CPPN可以使用多个几何函数作为节点激活器。因此,CPPNs可以在生成的连接模式中表达丰富的几何模式:

  • 对称性(高斯函数)

  • 不完美的对称性(高斯函数与不对称坐标框架相结合)

  • 重复(正弦函数)

  • 变化中的重复(正弦函数与不重复的坐标框架相结合)

考虑到我们讨论的CPPN的特征,我们可以假设它产生的连接模式可以表示表型ANN的任何网络拓扑。此外,连接模式可以通过在训练数据中发现规律并重复使用CPPN中的同一组基因来编码表型ANN中的重复来编码大规模拓扑。

基于超立方体的增强拓扑结构神经进化

上一个章节中描述的方法是由Kenneth O. Stanley发明的,被称为基于超立方体的 增强拓扑神经进化HyperNEAT)。正如其名称所暗示的,它是NEAT算法的扩展,我们已经在本书中使用过。这两种方法之间的主要区别是HyperNEAT方法使用基于CPPN的间接编码方案。在进化过程中,HyperNEAT方法使用NEAT算法进化一个基因组种群,这些基因组编码了连接CPPN的拓扑结构。之后,每个创建的CPPN都可以用来在特定表型ANN中建立连接模式。最后,表型ANN可以针对问题空间进行评估。

到目前为止,我们已经讨论了如何使用带有CPPN的NEAT进化连接模式,并将其应用于表型ANN的节点。然而,我们还没有提到节点几何布局最初是如何确定的。定义节点及其位置(布局)的责任分配给了人类建筑师。建筑师分析问题空间,并利用最合适的布局。

按照惯例,表型ANN节点的初始布局有一个名称:基质。存在几种基质配置(布局)类型,并且它们已经证明在特定任务中的效率:

  • 二维网格:以(0,0)为中心的两维笛卡尔空间中的网络节点规则网格。

  • 三维网格:以(0,0,0)为中心的三维笛卡尔空间中的网络节点规则网格。

  • 状态空间三明治:两个二维*面网格,其中包含相应的源节点和目标节点,其中一个层只能向另一个层的方向发送连接。

  • 圆形:一种适合基于极坐标定义径向几何规律性的规则径向结构。

通过在基质上适当地排列ANN节点,可以利用问题空间几何中的规律性。这通过使用连接CPPN在基质节点之间绘制连接模式,显著提高了编码的效率。现在让我们看看视觉辨别实验的基础。

关于HyperNEAT方法的更多细节,请参阅第1章神经进化方法概述

视觉辨别实验基础

正如我们已经提到的,HyperNEAT算法使用的间接编码的主要优势是能够编码大规模人工神经网络(ANN)的拓扑结构。在本节中,我们将描述一个可以用来测试HyperNEAT方法训练大规模ANN能力的实验。由于输入数据(图像高度乘以图像宽度)的高维性,视觉模式识别任务通常需要大型的ANN作为检测器。在本章中,我们考虑这一系列计算机科学问题中的一个变体,称为视觉识别任务。

视觉识别任务的目的是在二维视觉空间中区分大物体和小物体,无论它们在视觉空间中的位置以及它们相对于彼此的位置。视觉识别任务由一个专门的判别器ANN执行,该ANN建立在配置为状态空间三明治的两层底座上:

  • 视觉场是一个二维传感器数组,可以处于两种状态:开启或关闭(黑白)。

  • 目标域是一个二维输出数组,其激活值在[0,1]范围内。

视觉识别任务的方案如下所示:

图片

视觉识别任务

你可以在图中看到,要检测的对象被表示为两个由空隙分隔的正方形。较大的对象恰好比另一个大两倍。我们试图构建的算法需要检测较大对象的中心。检测基于测量目标场中ANN节点的激活值。激活值最高的节点的位置标志着检测到的对象的中心。我们的目标是发现视觉场和目标场之间正确的连接模式,使激活值最高的输出节点与视觉场中较大对象的中心对齐。此外,发现的连接模式应不受两个对象相对位置的影响。

用于视觉识别任务的算法需要评估大量输入——代表视觉场中细胞值的数值。此外,成功的算法还需要发现能够同时处理多个细胞输入的策略。这种策略应基于一个普遍原则,即允许检测视觉场中物体的大小相对值。在我们的实验中,视觉场被表示为一个二维网格。因此,要发现的普遍几何原则是局部性概念。

我们可以通过在视觉场和目标场节点连接方案中发现的特定模式来利用我们选择的辨别器ANN衬底配置中的局部性原理。在这个连接方案中,视觉场的单独节点连接到目标场特定位置周围的多个相邻输出节点。因此,输出节点收集的激活越多,通过连接向它提供的信号就越多。

为了有效地利用之前提到的局部性原理,连接的表示应考虑辨别器ANN衬底的几何形状以及正确的连接模式在整个衬底上重复的事实。这种表示的最佳候选者是CPPN,它一旦发现局部连接模式,就可以在任何分辨率上重复它在衬底网格上的模式。

目标函数定义

视觉辨别器的主要任务是正确确定较大物体的位置,无论两个物体的相对位置如何。因此,我们可以定义目标函数来指导神经进化过程。目标函数应基于视觉场中较大物体确切位置与其在目标场中预测位置之间的欧几里得距离。

损失函数可以直接表示为实际位置和预测位置之间的欧几里得距离,如下所示:

图片

图片 是损失函数,图片 是大物体的真实坐标,图片 是由辨别器ANN预测的坐标。

使用之前定义的损失函数,我们可以将目标函数写为以下形式:

图片

图片 是目标场空间内两点之间的最大可能距离。目标函数公式保证了计算出的适应度分数(图片)始终落在 [0,1] 范围内。现在我们了解了视觉辨别实验的基本知识,让我们开始设置它。

视觉辨别实验设置

在我们的实验中,在辨别器ANN的训练过程中,我们使用视觉场和目标场的分辨率固定在 11 x 11。因此,连接的CPPN必须学习视觉场121个输入和目标场121个输出之间的正确连接模式,这导致总共14,641个潜在的连接权重。

下图显示了辨别器ANN衬底的方案:

图片

辨别器ANN的状态空间夹层衬底

图中所示的判别器ANN具有两层,每层由形成二维*面网格的节点组成。连接性CPPN通过连接来自一个层的节点到另一个层的节点来绘制连接模式。

在进化的每一代中,种群中的每个个体(编码CPPN的基因组)都会对其创建判别器ANN连接模式的能力进行评估。然后测试判别器ANN是否能够在视觉场内找到大物体的中心。对于特定的ANN,总共有75次评估试验,其中每个试验中放置两个物体在不同的位置。在每个试验中,我们在视觉场中均匀分布的25个位置之一放置一个小物体。大物体的中心在小物体的右侧、下方或对角线方向五步之遥。如果大物体不能完全放入视觉场中,则它将绕到另一侧。因此,考虑到物体相对于彼此和网格的放置逻辑,我们应该能够在75次试验中评估所有可能的配置。

我们的实验设置有两个主要部分,我们将在接下来的几节中讨论。

视觉判别器测试环境

首先,我们需要定义测试环境并提供对数据集的访问,该数据集包含上一节中描述的所有可能的视觉场配置。本实验中使用的数据集是在测试环境初始化期间创建的。我们将在本节的后面讨论数据集的创建。

测试环境有两个主要组件:

  • 维护视觉场定义的数据结构

  • 测试环境管理器,它存储数据集,并提供一种方法来评估判别器ANN相对于它的性能

接下来,我们将详细描述这些组件。

视觉场定义

我们在VisualField Python类中存储了之前讨论的75次试验中每个试验的视觉场配置。它具有以下构造函数:

    def __init__(self, big_pos, small_pos, field_size):
        self.big_pos = big_pos
        self.small_pos = small_pos
        self.field_size = field_size
        self.data = np.zeros((field_size, field_size))

        # store small object position
        self._set_point(small_pos[0], small_pos[1])

        # store big object points
        offsets = [-1, 0, 1]
        for xo in offsets:
            for yo in offsets:
                self._set_point(big_pos[0] + xo, big_pos[1] + yo)

VisualField的构造函数接受一个包含大物体和小物体坐标(xy)的元组,以及视觉场的大小。我们考虑的是正方形视觉场,因此视觉场沿每个轴的大小相等。视觉场在内部表示为一个二维二进制数组,其中1表示被物体占据的位置,而0是空空间。它存储在self.data字段中,这是一个形状为(2,2)的NumPy数组。

小物体的大小为1 x 1,大物体是它的三倍大。以下是从构造函数源代码中创建大物体表示的片段:

        offsets = [-1, 0, 1]
        for xo in offsets:
            for yo in offsets:
                self._set_point(big_pos[0] + xo, big_pos[1] + yo)

VisualField类的构造函数接收大物体中心的坐标作为元组(xy)。前面的代码从左上角(x-1y-1)开始绘制大物体,并结束于右下角(x+1y+1)。

前面代码中提到的_set_point(self, x, y)函数在self.data字段中的特定位置设置1.0值:

    def _set_point(self, x, y):
        px, py = x, y
        if px < 0:
            px = self.field_size + px
        elif px >= self.field_size:
            px = px - self.field_size

        if py < 0:
            py = self.field_size + py
        elif py >= self.field_size:
            py = py - self.field_size

        self.data[py, px] = 1 # in Numpy index is: [row, col]

_set_point(self, x, y)函数在坐标值超过每轴允许的维度数时执行坐标包裹。例如,对于x轴,坐标值包裹的源代码如下:

        if px < 0:
            px = self.field_size + px
        elif px >= self.field_size:
            px = px - self.field_size

沿着y轴的坐标包裹源代码类似。

在需要的情况下,在函数参数指定的坐标包裹后,我们将self.data字段中相应的位置设置为1.0值。

NumPy索引为[行, 列]。因此,我们需要在索引的第一个位置使用y,在第二个位置使用x

可视判别环境

可视判别环境持有带有视觉场定义的生成数据集。它还提供了创建数据集和评估判别器ANN相对于数据集的方法。VDEnvironment Python类包含了所有提到的方法定义,以及相关的数据结构。接下来,我们将查看VDEnvironment类定义的所有重要部分:

  • 类构造函数定义如下:
    def __init__(self, small_object_positions, big_object_offset, 
                 field_size):
        self.s_object_pos = small_object_positions
        self.data_set = []
        self.b_object_offset = big_object_offset
        self.field_size = field_size

        self.max_dist = self._distance((0, 0), 
                             (field_size - 1, field_size - 1))

        # create test data set
        self._create_data_set()

VDEnvironment构造函数的第一个参数是一个数组,包含所有可能的小物体位置的定义,作为每个轴上坐标值的序列。第二个参数定义了大物体中心坐标相对于小物体坐标的偏移量。在我们的实验中,我们使用5作为此参数的值。最后,第三个参数是视觉场的大小,包括两个维度。

在所有接收到的参数都保存到对象字段后,我们计算视觉场中两点之间的最大可能距离如下:

self.max_dist = self._distance((0, 0), 
                     (field_size - 1, field_size - 1))

可视场左上角和右下角之间的欧几里得距离随后存储在self.max_dist字段中。此值将用于后续通过保持它们在[0, 1]范围内来归一化视觉场中点之间的距离。

  • _create_data_set()函数根据指定的环境参数创建所有可能的数据集。此函数的源代码如下:
    def _create_data_set(self):
        for x in self.s_object_pos:
            for y in self.s_object_pos:
                # diagonal
                vf = self._create_visual_field(x, y, 
                                  x_off=self.b_object_offset, 
                                  y_off=self.b_object_offset)
                self.data_set.append(vf)
                # right
                vf = self._create_visual_field(x, y, 
                                  x_off=self.b_object_offset,
                                  y_off=0)
                self.data_set.append(vf)
                # down
                vf = self._create_visual_field(x, y, 
                                  x_off=0, 
                                  y_off=self.b_object_offset)
                self.data_set.append(vf)

函数遍历两个轴上的小物体位置,并尝试在相对于小物体坐标的右侧、下方或对角线位置创建大物体。

  • _create_visual_field 函数使用小物体的坐标(sxsy)和大物体中心偏移量(x_offy_off)创建适当的视觉场配置。以下源代码显示了如何实现这一点:
    def _create_visual_field(self, sx, sy, x_off, y_off):
        bx = (sx + x_off) % self.field_size # wrap by X coordinate
        by = (sy + y_off) % self.field_size # wrap by Y coordinate

        # create visual field
        return VisualField(big_pos=(bx, by), small_pos=(sx, sy), 
                           field_size=self.field_size)

如果前面函数计算的大物体坐标超出了视觉场空间,我们按以下方式应用包装:

        if bx >= self.field_size:
            bx = bx - self.field_size # wrap

前面的代码片段显示了沿 x 轴的包装。沿 y 轴的包装类似。最后,创建并返回 VisualField 对象以附加到数据集中。

  • 然而,VDEnvironment 定义中最激动人心的部分与判别器 ANN 的评估有关,该评估在 evaluate_net(self, net) 函数中定义如下:
    def evaluate_net(self, net):
        avg_dist = 0

        # evaluate predicted positions
        for ds in self.data_set:
            # evaluate and get outputs
            outputs, x, y = self.evaluate_net_vf(net, ds)

            # find the distance to the big object
            dist = self._distance((x, y), ds.big_pos)
            avg_dist = avg_dist + dist

        avg_dist /= float(len(self.data_set))

        # normalized position error
        error = avg_dist / self.max_dist
        # fitness
        fitness = 1.0 - error

        return fitness, avg_dist

前面的函数接收判别器人工神经网络(ANN)作为参数,并返回评估的适应度分数以及所有评估视觉场中检测到的目标坐标与计算的所有真实值之间的*均距离。*均距离的计算如下:

        for ds in self.data_set:
            # evaluate and get outputs
            _, x, y = self.evaluate_net_vf(net, ds)

            # find the distance to the big object
            dist = self._distance((x, y), ds.big_pos)
            avg_dist = avg_dist + dist

        avg_dist /= float(len(self.data_set))

前面的源代码遍历数据集中的所有 VisualField 对象,并使用判别器 ANN 确定大物体的坐标。之后,我们计算真实值与预测位置之间的距离(检测误差)。最后,我们找到检测误差的*均值,并按以下方式归一化:

        # normalized detection error
        error = avg_dist / self.max_dist

根据前面的代码,最大可能的误差值是 1.0。适应度分数的值是误差值的 1.0 的补充,因为随着误差的减小而增加:

        # fitness
        fitness = 1.0 - error

evaluate_net 函数返回计算出的适应度分数以及未归一化的检测误差。

  • evaluate_net_vf(self, net, vf) 函数提供了一种评估判别器 ANN 对特定 VisualField 对象的方法。它定义如下:
   def evaluate_net_vf(self, net, vf):
        depth = 1 # we just have 2 layers

        net.Flush()
        # prepare input
        inputs = vf.get_data()
        net.Input(inputs)
        # activate
        [net.Activate() for _ in range(depth)]

        # get outputs
        outputs = net.Output()
        # find coordinates of big object
        x, y = self._big_object_coordinates(outputs)

        return outputs, x, y

前面的函数接收判别器 ANN 作为第一个参数,VisualField 对象作为第二个参数。之后,它从 VisualField 对象中获取展*的输入数组,并将其用作判别器 ANN 的输入:

        inputs = vf.get_data()
        net.Input(inputs)

在我们设置判别器 ANN 的输入之后,它必须被激活以将输入值传播到所有网络节点。我们的判别器 ANN 只有两层,这是由空间三明治底座配置确定的。因此,我们需要激活它两次——每层一次。在判别器 ANN 的两层中传播激活信号之后,我们可以确定目标场中大物体的位置,作为输出数组中最大值的索引。使用 _big_object_coordinates(self, outputs) 函数,我们可以提取目标场中大物体的笛卡尔坐标(xy)。

最后,evaluate_net_vf 函数返回原始输出数组以及目标字段空间中大物体的提取的笛卡尔坐标 (x, y)。

  • _big_object_coordinates(self, outputs) 函数从从判别器 ANN 获得的原始输出中提取目标字段空间中大物体的笛卡尔坐标。该函数的源代码如下:
    def _big_object_coordinates(self, outputs):
        max_activation = -100.0
        max_index = -1
        for i, out in enumerate(outputs):
            if out > max_activation:
                max_activation = out
                max_index = i

        # estimate the maximal activation's coordinates
        x = max_index % self.field_size
        y = int(max_index / self.field_size)

        return (x, y)

首先,该函数遍历输出数组并找到最大值的索引:

        max_activation = -100.0
        max_index = -1
        for i, out in enumerate(outputs):
            if out > max_activation:
                max_activation = out
                max_index = I

之后,它使用找到的索引来估计笛卡尔坐标,考虑到目标字段的大小:

        x = max_index % self.field_size
        y = int(max_index / self.field_size)

最后,该函数返回包含目标字段内大物体笛卡尔坐标的元组 (x, y)。

对于完整的实现细节,请查看 https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter7/vd_environment.py 中的 vd_environment.py

实验运行。

正如我们之前所描述的,视觉判别任务的解决方案可以使用 HyperNEAT 方法找到。因此,我们需要使用一个提供 HyperNEAT 算法实现的库。MultiNEAT Python 库是本实验的正确选择。因此,我们使用这个库来实现我们的实验。

接下来,我们讨论实验运行实现中最关键的部分。

对于完整的实现细节,请参阅 https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter7/vd_experiment_multineat.py 中的 vd_experiment_multineat.py

实验运行函数。

run_experiment 函数允许我们使用提供的超参数和初始化的视觉判别器测试环境来运行实验。函数实现包含以下部分。

初始化第一个 CPPN 基因组种群。

在以下代码中,首先,我们使用当前系统时间初始化随机数生成器种子。之后,我们为能够操作实验视觉字段维度的判别器 ANN 创建适当的基质配置。接下来,我们使用创建的基质配置创建 CPPN 基因组:

    # random seed
    seed = int(time.time())
    # Create substrate
    substrate = create_substrate(num_dimensions)
    # Create CPPN genome and population
    g = NEAT.Genome(0,
                    substrate.GetMinCPPNInputs(),
                    0,
                    substrate.GetMinCPPNOutputs(),
                    False,
                    NEAT.ActivationFunction.UNSIGNED_SIGMOID,
                    NEAT.ActivationFunction.UNSIGNED_SIGMOID,
                    0,
                    params, 0)
    pop = NEAT.Population(g, params, True, 1.0, seed)
    pop.RNG.Seed(seed)

在前面的代码中创建的 CPPN 基因组具有由基质提供的适当数量的输入和输出节点。最初,它使用无符号的 Sigmoid 作为节点激活函数。后来,在进化过程中,CPPN 中每个节点的激活函数类型将根据 HyperNEAT 算法流程进行更改。最后,使用初始化的 CPPN 基因组和 HyperNEAT 超参数创建初始种群。

在指定的代数内运行神经进化。

在本部分的开始,我们创建中间变量以保存执行结果,并创建统计收集器(Statistics)。之后,我们根据n_generations参数指定的代数执行进化循环:

    start_time = time.time()
    best_genome_ser = None
    best_ever_goal_fitness = 0
    best_id = -1
    solution_found = False

    stats = Statistics()
    for generation in range(n_generations):

在进化循环中,我们获取当前代种群所属的基因组列表,并将列表中的所有基因组与测试环境进行评估,如下所示:

        genomes = NEAT.GetGenomeList(pop)
        # evaluate genomes
        genome, fitness, distances = eval_genomes(genomes, 
                                      vd_environment=vd_environment, 
                                      substrate=substrate, 
                                      generation=generation)
        stats.post_evaluate(max_fitness=fitness, distances=distances)
        solution_found = fitness >= FITNESS_THRESHOLD

我们将当前代eval_genomes(genomes, substrate, vd_environment, generation)函数返回的值保存到统计收集器中。我们还使用eval_genomes返回的适应度分数来估计是否找到了成功的解决方案。如果适应度分数超过FITNESS_THRESHOLD值,我们认为找到了成功的解决方案。

如果找到成功的解决方案或当前适应度分数是迄今为止达到的最大适应度分数,我们将保存CPPN基因组和当前适应度分数:

        if solution_found or best_ever_goal_fitness < fitness:
            best_genome_ser = pickle.dumps(genome)
            best_ever_goal_fitness = fitness
            best_id = genome.GetID()

此外,如果找到成功的解决方案,我们将退出进化循环,并进入后续的报表步骤,我们将在后面讨论:

        if solution_found:
            print('Solution found at generation: %d, best fitness: %f, species count: %d' % (generation, fitness, len(pop.Species)))
            break

如果没有找到成功的解决方案,我们将打印当前代的统计数据,并使用以下代码进入下一代:

        # advance to the next generation
        pop.Epoch()
        # print statistics
        gen_elapsed_time = time.time() - gen_time
        print("Best fitness: %f, genome ID: %d" % (fitness, best_id))
        print("Species count: %d" % len(pop.Species))
        print("Generation elapsed time: %.3f sec" % (gen_elapsed_time))
        print("Best fitness ever: %f, genome ID: %d" 
               % (best_ever_goal_fitness, best_id))

在主要进化循环之后,报告实验结果,这使用了循环中收集的统计数据。

保存实验结果

实验结果以文本和图形表示(SVG文件)的形式报告和保存。我们首先打印以下一般性能统计数据:

    print("\nBest ever fitness: %f, genome ID: %d" 
          % (best_ever_goal_fitness, best_id))
    print("\nTrial elapsed time: %.3f sec" % (elapsed_time))
    print("Random seed:", seed)

前述代码的前三行将所有进化代数中获得的最佳适应度分数打印到控制台。之后,我们打印实验的已用时间和使用的随机种子值。

如果我们请求保存或显示可视化,将调用相应的函数:

    # Visualize the experiment results
    show_results = not silent
    if save_results or show_results:
        net = NEAT.NeuralNetwork()
        best_genome.BuildPhenotype(net)
        visualize.draw_net(net, view=show_results, node_names=None, 
                           directory=trial_out_dir, fmt='svg')

前述代码绘制了CPPN的网络图,并打印了图的统计数据。

接下来,我们转向判别器ANN输出的可视化:


        # Visualize activations from the best genome
        net = NEAT.NeuralNetwork()
        best_genome.BuildHyperNEATPhenotype(net, substrate)
        # select random visual field
        index = random.randint(0, len(vd_environment.data_set) - 1)
        vf = vd_environment.data_set[index]
        # draw activations
        outputs, x, y = vd_environment.evaluate_net_vf(net, vf)
        visualize.draw_activations(outputs, found_object=(x, y), vf=vf,
                  dimns=num_dimensions, view=show_results, 
                  filename=os.path.join(trial_out_dir, 
                                        "best_activations.svg"))

在前述代码中,我们使用在进化过程中找到的最佳CPPN基因组创建判别器ANN。之后,我们绘制通过在测试环境中评估判别器ANN获得的激活输出。我们使用从实验数据集中随机选择的视野。

最后,我们渲染实验期间收集的一般统计数据:

        # Visualize statistics
        visualize.plot_stats(stats, ylog=False, view=show_results, 
                  filename=os.path.join(trial_out_dir, 'avg_fitness.svg'))

统计图包括在进化代数中绘制的最佳适应度分数和*均误差距离。

关于本节中提到的可视化函数的实现细节,请参阅https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter7/visualize.py中的visualize.py

底层构建函数

HyperNEAT 方法建立在底层概念的基础上,该底层定义了判别器 ANN 的结构。因此,在实验执行期间创建一个合适的底层配置至关重要。底层创建例程定义在以下两个函数中:

  • 底层构建函数 create_substrate 如下创建底层对象:
def create_substrate(dim):
    # Building sheet configurations of inputs and outputs
    inputs = create_sheet_space(-1, 1, dim, -1)
    outputs = create_sheet_space(-1, 1, dim, 0)
    substrate = NEAT.Substrate( inputs, [], # hidden outputs)
    substrate.m_allow_input_output_links = True
    ...
    substrate.m_hidden_nodes_activation = \
                  NEAT.ActivationFunction.SIGNED_SIGMOID
    substrate.m_output_nodes_activation = \
                  NEAT.ActivationFunction.UNSIGNED_SIGMOID
    substrate.m_with_distance = True
    substrate.m_max_weight_and_bias = 3.0
    return substrate

前面的函数首先创建了两个基于网格的笛卡尔纸张,分别代表底层配置的输入(视觉场)和输出(目标场)。记住,对于这个实验,我们选择了状态空间三明治底层配置。之后,使用创建的字段配置初始化了底层实例:

    inputs = create_sheet_space(-1, 1, dim, -1)
    outputs = create_sheet_space(-1, 1, dim, 0)
    substrate = NEAT.Substrate( inputs, [], # hidden outputs)

请注意,底层不使用任何隐藏节点;我们提供空列表代替隐藏节点。

接下来,我们配置底层以仅允许从输入节点到输出节点的连接,并在输出节点使用有符号的 sigmoid 激活函数。最后,我们设置偏差和连接权重的最大值。

  • 由底层构建函数调用的 create_sheet_space 函数定义如下:
def create_sheet_space(start, stop, dim, z):
    lin_sp = np.linspace(start, stop, num=dim)
    space = []
    for x in range(dim):
        for y in range(dim):
            space.append((lin_sp[x], lin_sp[y], z))

    return space

create_sheet_space 函数接收一个维度内网格的起始和结束坐标以及网格维度的数量。同时,提供纸张的 z 坐标。使用指定的参数,前面的代码创建了一个以 [start, stop] 范围开始,步长为 dim 的均匀线性空间:

    lin_sp = np.linspace(start, stop, num=dim)

之后,我们使用这个线性空间如下填充二维数组,其中包含网格节点的坐标:

    space = []
    for x in range(dim):
        for y in range(dim):
            space.append((lin_sp[x], lin_sp[y], z))

create_sheet_space 函数以二维数组的形式返回网格配置。

健身评估

基因组的适应性评估是任何神经进化算法,包括 HyperNEAT 方法的一个重要部分。正如你所看到的,主要实验循环调用 eval_genomes 函数来评估每一代种群中所有基因组的适应性。在这里,我们考虑了适应性评估例程的实现细节,它由两个主要函数组成:

  • eval_genomes 函数评估种群中的所有基因组:
def eval_genomes(genomes, substrate, vd_environment, generation):
    best_genome = None
    max_fitness = 0
    distances = []
    for genome in genomes:
        fitness, dist = eval_individual(genome, substrate, 
                                        vd_environment)
        genome.SetFitness(fitness)
        distances.append(dist)

        if fitness > max_fitness:
            max_fitness = fitness
            best_genome = genome

    return best_genome, max_fitness, distances

eval_genomes 函数接受一个基因组列表、判别器 ANN 底层配置、初始化的测试环境和当前代的 ID 作为参数。函数的前几行创建了一些中间变量,用于存储评估结果:

    best_genome = None
    max_fitness = 0
    distances = []

之后,我们遍历种群中的所有基因组并收集适当的统计数据:

    for genome in genomes:
        fitness, dist = eval_individual(genome, substrate, 
                                        vd_environment)
        genome.SetFitness(fitness)
        distances.append(dist)

        if fitness > max_fitness:
            max_fitness = fitness
            best_genome = genome

最后,eval_genomes 函数以元组的形式返回收集到的统计数据,(best_genome, max_fitness, distances)

  • eval_individual 函数允许我们如下评估单个基因组的适应性:
def eval_individual(genome, substrate, vd_environment):
    # Create ANN from provided CPPN genome and substrate
    net = NEAT.NeuralNetwork()
    genome.BuildHyperNEATPhenotype(net, substrate)

    fitness, dist = vd_environment.evaluate_net(net)
    return fitness, dist

在开始时,前面的源代码使用提供的 CPPN 基因创建判别器 ANN 表型。之后,判别器 ANN 表型在测试环境中进行评估。

eval_individual 函数返回在表型评估期间从测试环境中获得的适应度分数和误差距离。现在我们已经完成了设置,让我们开始进行视觉辨别实验。

视觉辨别实验

完成所有必要的设置步骤后,我们准备开始实验。

在视觉辨别实验中,我们使用以下配置的视野:

参数
视野大小 11 x 11
视野中每个轴上小物体的位置 [1, 3, 5, 7, 9]
小物体的大小 1 x 1
大物体的大小 3 x 3
大物体中心相对于小物体的偏移量 5

接下来,我们需要选择合适的 HyperNEAT 超参数值,以便找到视觉辨别问题的成功解决方案。

注意,我们接下来描述的超参数决定了如何使用神经进化过程进化连接的 CPPN。判别器 ANN 通过将连接的 CPPN 应用到基质中创建。

超参数选择

MultiNEAT 库使用 Parameters Python 类来保存所有必需的超参数。为了设置超参数的适当值,我们在实验运行器 Python 脚本中定义了 create_hyperparameters 函数。在这里,我们描述了在这次实验中对 HyperNEAT 算法性能有重大影响的必要超参数:

  1. create_hyperparameters 函数首先创建一个 Parameters 对象来保存 HyperNEAT 参数:
    params = NEAT.Parameters()
  1. 我们决定从一个中等大小的基因组群体开始,以保持计算快速。同时,我们希望在群体中保持足够多的生物体以维持进化多样性。群体大小如下定义:
    params.PopulationSize = 150
  1. 我们对产生尽可能少的节点的紧凑型 CPPN 基因组感兴趣,以增加间接编码的有效性。因此,我们在进化过程中设置了极小的添加新节点的概率,并且保持创建新连接的概率相当低:
    params.MutateAddLinkProb = 0.1
    params.MutateAddNeuronProb = 0.03
  1. HyperNEAT 方法在隐藏节点和输出节点中产生具有不同激活函数的 CPPN 基因组。因此,我们定义了改变节点激活类型变异的概率。此外,在这个实验中,我们只对使用四种类型的激活函数感兴趣:符号高斯、符号 S 型、符号正弦和线性。我们将选择这四种激活类型中任何一种的概率设置为 1.0,这实际上使得选择每种类型的概率相等。我们如下定义了超参数:
    params.MutateNeuronActivationTypeProb = 0.3
    params.ActivationFunction_SignedGauss_Prob = 1.0
    params.ActivationFunction_SignedSigmoid_Prob = 1.0
    params.ActivationFunction_SignedSine_Prob = 1.0
    params.ActivationFunction_Linear_Prob = 1.0
  1. 最后,我们定义种群中要保留的物种数量在[5,10]范围内,并将物种停滞参数的值设置为100代。此配置保持了适度的物种多样性,但足以让物种长时间存在,以便它们可以进化并产生有用的CPPN基因组配置:
    params.SpeciesMaxStagnation = 100
    params.MinSpecies = 5
    params.MaxSpecies = 10

这里展示的超参数选择展示了在进化过程中产生成功的CPPN基因组的效率之高。

工作环境设置

在这个实验中,我们使用MultiNEAT Python库,它提供了HyperNEAT算法的实现。因此,我们需要创建一个合适的Python环境,这包括MultiNEAT Python库和所有必要的依赖项。这可以通过在命令行中执行以下命令使用Anaconda来完成:

$ conda create --name vd_multineat python=3.5
$ conda activate vd_multineat
$ conda install -c conda-forge multineat 
$ conda install matplotlib
$ conda install -c anaconda seaborn
$ conda install graphviz
$ conda install python-graphviz

这些命令创建并激活了一个vd_multineat虚拟环境,使用Python 3.5。之后,它们安装了MultiNEAT Python库的最新版本,以及我们代码用于结果可视化的依赖项。

运行视觉判别实验

要开始实验,你需要进入包含vd_experiment_multineat.py脚本的本地目录,并执行以下命令:

$ python vd_experiment_multineat.py

不要忘记使用以下命令激活适当的虚拟环境:

$ conda activate vd_multineat

在经过特定代数之后,成功解决方案将被找到,你将在控制台输出中看到类似以下内容的行:

****** Generation: 16 ******

Best fitness: 0.995286, genome ID: 2410
Species count: 11
Generation elapsed time: 3.328 sec
Best fitness ever: 0.995286, genome ID: 2410

****** Generation: 17 ******

Solution found at generation: 17, best fitness: 1.000000, species count: 11

Best ever fitness: 1.000000, genome ID: 2565

Trial elapsed time: 57.753 sec
Random seed: 1568629572

CPPN nodes: 10, connections: 16

Running test evaluation against random visual field: 41
Substrate nodes: 242, connections: 14641
found (5, 1)
target (5, 1)

控制台输出表明解决方案在第17代找到。成功CPPN基因组的ID是2565,这个基因组有10个节点和它们之间的16个连接。你还可以看到由最佳CPPN基因组产生的判别器ANN对随机选择的视觉场的评估结果。

在这种情况下,检测到的目标场中大型物体的笛卡尔坐标和视觉场中的实际坐标相同(5,1),这意味着找到的解决方案能够以精确的精度进行视觉判别。

接下来,查看在测试评估期间获得的判别器ANN的激活输出的可视化是非常有趣的:

图片

判别器ANN的目标场激活

前一个图的右侧显示了在评估随机视觉场时获得的判别器ANN的目标字段(输出层)的激活值。同样,在图的左侧,你可以看到实际的视觉场配置。正如你所看到的,最大目标字段激活值(最暗的单元格)正好位于视觉场中大型物体的中心位置,坐标为(51)。

从前面的图中可以看出,ANN激活值的尺度极低:最小激活值为~1e-13,最大值仅为~9e-13。一个由人类设计的ANN可能会进行归一化处理,使得输出在[0,1]范围内,最小值接*零,最大值接*一。然而,我们只要求激活值在正确的位置达到最大值,网络可以自由选择大多数人认为不寻常的输出激活方案。

另一个图允许你研究进化过程中每一代的表现以及产生的连接CPPN在创建成功的判别ANN中的表现如何:

判别ANN的最佳适应度分数和*均误差距离

前面的图显示了每一代进化过程中适应度分数(上升线)和*均误差距离(下降线)的变化。你可以看到,适应度分数在进化的第三代几乎达到了最大值,并且需要再经过七代来详细阐述CPPN基因配置,最终找到胜者。此外,你还可以看到,在进化过程中,检测到的物体位置与真实位置之间的*均误差距离逐渐减小。

然而,这个实验最激动人心的部分体现在以下CPPN表型图的图中:

最佳基因的CPPN表型图

该图展示了用于在判别ANN上绘制连接的最佳CPPN表型网络拓扑。在CPPN表型图中,输入节点用方块标记,输出节点用实心圆圈标记,偏置节点用菱形标记。

CPPN的两个输出节点有以下含义:

  • 第一个节点(8)提供连接的权重。

  • 第二个节点(9)确定连接是否表达。

CPPN输入节点定义如下:

  • 前两个节点(0和1)在基底的输入层中设置点坐标(xy)。

  • 接下来的两个节点(2和3)在基底的隐藏层中设置点坐标(xy)(在我们的实验中未使用)。

  • 接下来的两个节点(4和5)在基底的输出层中设置点坐标(xy)。

  • 最后一个节点(6)设置输入层中点与坐标原点的欧几里得距离。

此外,你还可以看到CPPN表型中不包含任何隐藏节点。对于视觉判别任务,神经进化过程能够找到适合CPPN输出节点的适当激活函数类型。这一发现使得连接CPPN能够在判别ANN的基底中绘制正确的连接模式。

通过计算前述图中节点和它们之间的连接数量,您可以感受到HyperNEAT算法引入的间接编码方法的威力。仅用10个节点之间的16个连接,CPPN表型就能揭示底物的连接模式,对于11 x 11分辨率的视觉场,节点之间和目标场之间的连接数最多可达14,641个。因此,我们实现了大约0.11%的信息压缩率,这相当令人印象深刻。

这样高的压缩率是由于连接CPPN在底物连接基元中发现了几何规律。利用发现的模式规律,CPPN只需存储整个底物连接空间中的少量模式(局部连接基元),然后可以在不同的底物位置多次应用这些局部模式,以绘制底物层之间的完整连接方案。在我们的案例中,是为了绘制输入层(视觉场)和输出层(目标场)之间的连接。

练习

  1. 尝试降低params.PopulationSize超参数的值,看看会发生什么。这对算法的性能有何影响?

  2. 尝试将以下超参数的值设置为0概率:params.ActivationFunction_SignedGauss_Probparams.ActivationFunction_SignedSigmoid_Probparams.ActivationFunction_SignedSine_Prob。这些更改是否找到了成功的解决方案?这对底物连接的配置有何影响?

  3. 打印出获胜的基因组,尝试创建一个可视化,然后看看您从基因组中获得的直观感受与可视化的CPPN是否匹配。

摘要

在本章中,我们学习了使用CPPN间接编码ANN拓扑结构的方法。您了解了NEAT算法的HyperNEAT扩展,该扩展使用连接CPPN在判别器ANN的表型底物中绘制连接模式。我们还展示了间接编码方案如何使HyperNEAT算法能够处理大规模ANN拓扑结构,这在模式识别和视觉辨别任务中很常见。

在我们提供的理论背景下,您有机会通过使用Python和MultiNEAT库实现视觉辨别任务的解决方案来提高您的编码技能。此外,您还了解了一种新的可视化方法,该方法可以渲染判别器ANN输出层中节点的激活值,以及如何使用这种可视化来验证解决方案。

在下一章中,我们将讨论如何通过引入一种自动生成适当基板配置的方法来进一步改进HyperNEAT方法。我们将考虑NEAT算法的可进化基板HyperNEATES-HyperNEAT)扩展,并看看它如何应用于解决需要求解器ANN模块化拓扑结构的实际任务。

第十一章:ES-HyperNEAT与视网膜问题

在本章中,你将了解HyperNEAT方法的ES-HyperNEAT扩展,这是我们上章讨论过的。正如你在上章所学,HyperNEAT方法允许编码更大规模的人工神经网络ANN)拓扑结构,这对于在输入数据具有大量维度的领域工作至关重要,例如计算机视觉。然而,尽管HyperNEAT方法功能强大,但它有一个显著的缺点——ANN基底的配置应该由人类建筑师事先设计。ES-HyperNEAT方法通过引入可进化基底的观念来解决这一问题,这使得我们能够在进化过程中自动产生适当的基底配置。

在熟悉ES-HyperNEAT方法的基础知识之后,你将有机会将此知识应用于解决模块化视网膜问题。在此任务中,我们将向你展示如何选择一个合适的初始基底配置,以帮助进化过程发现模块化结构。此外,我们还将讨论模块化视网膜问题求解器的源代码以及测试环境,这些可以用来评估每个检测ANN的适应性。

通过本章,你将获得使用MultiNEAT Python库应用ES-HyperNEAT方法的实践经验。

本章中,我们将涵盖以下主题:

  • 神经节点拓扑的手动配置与基于进化的配置

  • 四叉树信息提取和ES-HyperNEAT基础知识

  • 模块化左右视网膜实验

  • 实验结果讨论

技术要求

为了执行本章中描述的实验,应满足以下技术要求:

  • Windows 8/10, macOS 10.13或更新的版本,或现代Linux

  • Anaconda Distribution版本2019.03或更新的版本

本章的代码可以在https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/tree/master/Chapter8找到

神经节点拓扑的手动配置与基于进化的配置

我们在第7章“基于超立方体的NEAT进行视觉区分”中讨论的HyperNEAT方法,允许我们使用神经进化方法解决需要使用大规模ANN结构来找到解决方案的广泛类别的难题。这类问题跨越多个实际领域,包括视觉模式识别。所有这些问题的主要区别特征是输入/输出数据的高度维度。

在上一章中,你学习了如何定义判别器人工神经网络(ANN)的基底的配置来解决视觉判别任务。你也了解到,使用与目标问题搜索空间几何特征相匹配的适当基底配置至关重要。使用HyperNEAT方法,作为架构师的你需要事先定义基底配置,仅使用你对问题空间空间几何的理解。然而,并不总是可能了解特定问题空间背后隐藏的所有几何规律。

如果你手动设计基底,你会在其上绘制的权重模式上创建一个无意中的约束,这种模式是由连接组合模式生成网络CPPNs)产生的。通过在基底中特定位置放置节点,你干扰了CPPN发现自然世界几何规律的能力。CPPN应该产生一个与提供的基底结构完美对齐的连接模式,并且只有该结构中的节点之间才能建立连接。这种限制导致了不必要的*似误差,当你使用演化的CPPN创建解决方案求解器ANN(表型)的拓扑结构时,这些误差会污染结果。

然而,为什么手动配置基底时引入的限制一开始就产生了?如果CPPN能够详细阐述基底中自动定位在正确位置的节点之间的连接模式会更好吗?似乎在基底中演化的连接模式提供了有价值的隐含提示,帮助我们估计下一轮演化的节点位置。在CPPN训练期间基底配置演化的方法得到了一个名字:可演化基底

允许我们估计下一个节点位置的隐含数据是特定基底区域中连接模式编码的信息量。连接权重均匀分布的区域编码的信息量较小,因此在这些区域只需要少量基底节点。同时,连接权重梯度大的基底区域信息密集,可以从放置在这些区域内的额外节点中受益。当你将额外的节点放置在基底这样的区域时,你允许CPPN表示自然世界的更细粒度的编码。因此,节点的放置和连接模式可以由连接权重的分布来规定,而CPPN在进化过程中产生连接权重。

HyperNEAT将基底中两个节点之间的每个连接表示为四维超立方体中的一个点。可进化基底HyperNEAT算法通过自动在连接权重变化较小的超立方体区域放置较少的超点来扩展HyperNEAT。因此,ES-HyperNEAT在进化过程中确定基底拓扑时,将信息密度作为主要的指导原则。

在下一节中,我们将讨论ES-HyperNEAT算法的细节。

四叉树信息提取和ES-HyperNEAT基础知识

为了有效计算基底的连接模式中的信息密度,我们需要使用适当的数据结构。我们需要使用一种数据结构,允许在不同粒度级别上有效地搜索二维基底空间。在计算机科学中,存在一种数据结构完美符合这些要求。这种结构就是四叉树

四叉树是一种数据结构,允许我们通过将任何感兴趣的区域分割成四个子区域来有效地在二维空间中进行搜索。每个子区域随后成为树的叶子,根节点代表初始区域。

ES-HyperNEAT使用四叉树数据结构,从数据科学家预定义的输入和输出节点开始,迭代地寻找基底中的新连接和节点。使用四叉树搜索新连接和节点比在超立方体的四维空间中搜索要计算效率高得多。

以下图表显示了使用四叉树结构的信息提取方案:

图片

信息提取方案

图中所示的信息提取方法有两个主要部分:

  1. 划分和初始化阶段在图表的上部部分展示。在这个阶段,通过递归划分初始基底区域(从(-1-1)到(1, 1)),创建四叉树。当达到所需的四叉树深度时,划分停止。现在我们有几个子空间被拟合到基底中,确定初始基底分辨率(r)。接下来,对于四叉树中中心在(,)的每个节点,我们查询CPPN以找到该节点与特定输入或输出神经元在坐标(a, b)之间的连接权重(w)。当我们计算出四叉树子树中k个叶子节点的连接权重后,我们就可以计算四叉树中节点p的信息方差,如下所示:

图片

k个叶节点之间的*均连接权重,是每个叶节点的连接权重。

我们可以使用这个估计的方差值作为信息密度在基质特定子区域中的启发式指标。这个值越高,信息密度就越高。可以通过引入分割阈值常量来使用方差来管理基质特定子区域的信息密度。如果方差大于分割阈值,则重复分割阶段,直到达到所需的信息密度。

在这个阶段,我们创建一个指示性结构,使CPPN能够决定在给定的基质中连接的位置。处理阶段的下一阶段使用创建的四叉树结构放置所有必要的连接。

  1. 修剪和提取阶段在图的下部表示。在这个阶段,我们使用前一阶段创建的填充四叉树结构来找到高方差区域,并确保这些区域的节点之间有更多的连接。我们以深度优先的方式遍历四叉树,并在具有小于给定方差阈值的方差值的节点处停止遍历()或当当前节点没有子节点(即,方差为零)时。对于通过深度优先搜索找到的每个四叉树节点,我们表达节点中心(x, y)与每个已确定的父节点之间的连接。父节点可以是建筑师(输入/输出节点)确定的,也可以在信息提取方法的前一轮次中找到,即从ES-HyperNEAT方法已创建的隐藏节点中找到。当这个阶段完成时,基质配置将在信息密集的基质区域有更多的节点,而在编码少量信息的区域有较少的节点。

在下一节中,我们将讨论如何使用我们刚刚描述的ES-HyperNEAT算法来解决模块化视网膜问题。

关于ES-HyperNEAT算法的更多详细信息,请参阅第1章神经进化方法概述

模块化视网膜问题基础知识

层次模块结构是复杂生物体的基本组成部分,在它们的进化中起着不可或缺的作用。模块化增强了可进化性,允许在进化过程中重组各种模块。模块组件的进化层次启动了进化过程,允许对一组复杂结构进行操作,而不是基本基因。之后,神经进化过程不需要再次从头开始进化类似的功能。相反,现成的模块组件可以作为构建块来构建非常复杂的神经网络。

在本章中,我们将使用ES-HyperNEAT算法实现视网膜问题的解决方案。视网膜问题涉及同时识别人工视网膜左侧和右侧的有效的2x2模式,该人工视网膜的分辨率为4x2。因此,检测器ANN必须决定视网膜左侧和右侧呈现的模式是否对应视网膜的相应侧(左侧或右侧)。

在视网膜问题中,左右问题组件被完美地分离到不同的功能单元中。同时,一些组件可以出现在视网膜的每一侧,而其他组件则仅限于视网膜的特定部分。因此,为了产生一个成功的检测器ANN,神经进化过程需要分别发现左右检测区域的模块结构。

视网膜问题方案如下所示:

图片

视网膜问题方案

如前图所示,人工视网膜被表示为一个分辨率为4x2像素的2D网格。表示视网膜上绘制的模式的二维数组的值构成了检测器ANN的输入。数组中的填充像素的值为1.0,空像素的值为0.0。在给定的分辨率下,可以为视网膜的左右两侧绘制16种不同的2x2模式。因此,视网膜的左侧有八个有效模式,右侧也有八个有效模式。所提到的某些模式对视网膜的两侧都有效。

在视网膜问题领域中,检测器ANN的决策方案如下:

图片

检测器ANN的决策方案

检测器人工神经网络(ANN)有八个输入来接受视网膜两边的输入数据模式,并且有两个输出节点。每个输出节点产生一个值,可以用来分类视网膜每边的模式的有效性。第一个输出节点分配给左侧,第二个节点分配给视网膜的右侧。输出节点的激活值大于或等于0.5时,将视网膜相关侧的模式分类为有效。如果激活值小于0.5,则模式被认为无效。为了进一步简化检测,我们根据图中的舍入方案对输出节点的值进行舍入。因此,检测器ANN的每个输出节点作为相关视网膜部分的二元分类器,产生0.01.0的值来相应地标记输入模式为无效或有效。

目标函数定义

检测器ANN的任务是通过产生具有0.01.0值的二元输出向量,正确地将视网膜左右两侧的输入分类为有效或无效。输出向量的长度为2,等于输出节点的数量。

我们可以将检测误差定义为真实值向量与ANN输出值向量之间的欧几里得距离,如下公式所示:

图片

图片是单个试验的*方检测误差,图片是检测器ANN的输出向量,而图片是真实值向量。

在进化的每一代中,我们评估每个检测器ANN(表型)对所有256种可能的4x4视网膜模式组合,这些模式是通过将每侧视网膜的16个不同的2x2模式组合而产生的。因此,为了得到特定检测器ANN的最终检测误差值,我们计算视网膜模式配置获得的256个误差值的总和,如下公式所示:

图片

图片是256次试验期间获得的所有误差的总和,而图片是特定试验的*方检测误差。

适应度函数可以被定义为从所有256次试验中获取的所有可能的视网膜模式误差之和的倒数,如下公式所示:

图片

我们将1.0加到分母中错误的和(图片)以避免在所有试验均无错误的情况下除以0。因此,根据适应度函数公式,我们实验中适应度分数的最大值为1000.0,我们将在稍后将其用作适应度阈值值。

模块化视网膜实验设置

在本节中,我们讨论了旨在创建模块化视网膜问题成功求解器的实验的细节。在我们的实验中,我们将此问题作为基准来测试ES-HyperNEAT方法在表型ANN中发现模块化拓扑结构的能力。

初始基底配置

如本章前面所述,视网膜的尺寸为4x2,有两个2x2区域,一个在左侧,一个在右侧。视网膜几何的细节必须在初始基底配置的几何中表示。在我们的实验中,我们使用三维基底,如下所示:

图片

初始基底配置

如图中所示,输入节点位于XZ*面内,该*面垂直于XY*面。它们分为两组,每组四个节点用于描述视网膜的左右两侧。两个输出和偏置节点位于XY*面内,该*面将Z*面一分为二,与输入节点相邻。基底的演变在输出节点所在的同一XY*面中创建了新的隐藏节点。演化的连接性CPPN绘制了基底内所有节点之间的连接模式。我们的最终目标是演化CPPN和基底配置,从而产生检测器ANN的适当模块化图。此图应包括两个模块,每个模块代表二进制分类器的适当配置,这是我们之前讨论过的。现在让我们看看模块化视网膜问题的测试环境。

模块化视网膜问题的测试环境

首先,我们需要创建一个测试环境,用于评估旨在创建成功的检测器ANN的神经进化过程的成果。测试环境应创建一个数据集,包括视网膜上所有可能的像素模式。此外,它还应提供评估检测器ANN与数据集中每个模式的功能。因此,测试环境可以分为两个主要部分:

  • 用于存储视网膜左侧、右侧或两侧视觉模式的数结构

  • 存储数据集并提供检测器ANN评估功能的测试环境

在以下章节中,我们提供了每个部分的详细描述。

视觉对象定义

视网膜空间特定部分的像素允许配置的每种配置都可以表示为一个单独的视觉对象。封装相关功能的Python类命名为VisualObject,并在retina_experiment.py文件中定义。它具有以下构造函数:

    def __init__(self, configuration, side, size=2):
        self.size = size
        self.side = side
        self.configuration = configuration
        self.data = np.zeros((size, size))

        # Parse configuration
        lines = self.configuration.splitlines()
        for r, line in enumerate(lines):
            chars = line.split(" ")
            for c, ch in enumerate(chars):
                if ch == 'o':
                    # pixel is ON
                    self.data[r, c] = 1.0
                else:
                    # pixel is OFF
                    self.data[r, c] = 0.0

构造函数接收一个特定视觉对象的配置字符串,以及该对象在视网膜空间中的有效位置。之后,它将接收到的参数分配给内部字段,并创建一个二维数据数组,用于存储视觉对象中像素的状态。

通过以下方式解析视觉对象配置字符串以获取像素的状态:

        # Parse configuration
        lines = self.configuration.splitlines()
        for r, line in enumerate(lines):
            chars = line.split(" ")
            for c, ch in enumerate(chars):
                if ch == 'o':
                    # pixel is ON
                    self.data[r, c] = 1.0
                else:
                    # pixel is OFF
                    self.data[r, c] = 0.0

视觉对象配置字符串由四个字符组成,不包括换行符,这些字符定义了视觉对象中相应像素的状态。如果配置行中特定位置的符号是o,则将视觉对象中相应位置的像素设置为开启状态,并将值1.0保存到数据数组中的该位置。

视网膜环境定义。

视网膜环境创建并存储由所有可能的视觉对象组成的数据集,并提供评估检测器ANN适应度的函数。它具有以下主要实现部分。

创建包含所有可能视觉对象的数据集的功能。

在此函数中,我们按照以下方式创建数据集的视觉对象:

    def create_data_set(self):
        # set left side objects
        self.visual_objects.append(VisualObject(". .\n. .", 
                                                side=Side.BOTH))
        self.visual_objects.append(VisualObject(". .\n. o", 
                                                side=Side.BOTH))
        self.visual_objects.append(VisualObject(". o\n. o", 
                                                side=Side.LEFT))
        self.visual_objects.append(VisualObject(". o\n. .", 
                                                side=Side.BOTH))
        self.visual_objects.append(VisualObject(". o\no o", 
                                                side=Side.LEFT))
        self.visual_objects.append(VisualObject(". .\no .", 
                                                side=Side.BOTH))
        self.visual_objects.append(VisualObject("o o\n. o", 
                                                side=Side.LEFT))
        self.visual_objects.append(VisualObject("o .\n. .", 
                                                side=Side.BOTH))

上述代码创建了视网膜左侧的视觉对象。右侧的视觉对象可以以类似的方式创建:

       # set right side objects
       self.visual_objects.append(VisualObject(". .\n. .", 
                                               side=Side.BOTH))
       self.visual_objects.append(VisualObject("o .\n. .", 
                                               side=Side.BOTH))
       self.visual_objects.append(VisualObject("o .\no .", 
                                               side=Side.RIGHT))
       self.visual_objects.append(VisualObject(". .\no .", 
                                               side=Side.BOTH))
       self.visual_objects.append(VisualObject("o o\no .", 
                                               side=Side.RIGHT))
       self.visual_objects.append(VisualObject(". o\n. .", 
                                               side=Side.BOTH))
       self.visual_objects.append(VisualObject("o .\no o", 
                                               side=Side.RIGHT))
       self.visual_objects.append(VisualObject(". .\n. o", 
                                               side=Side.BOTH))

创建的对象被添加到视觉对象列表中,定义为评估从基底产生的神经进化过程中检测器ANN适应度的数据集。

评估检测器ANN针对两个特定视觉对象的功能。

此功能评估检测器ANN针对两个给定的视觉对象(每个视觉对象对应视网膜空间的一侧)的性能。对于完整的源代码,请参阅在https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter8/retina_environment.py中定义的def _evaluate(self, net, left, right, depth, debug=False)函数。

函数的源代码具有以下基本部分:

  1. 首先,我们按照在基底配置中定义的顺序准备检测器人工神经网络(ANN)的输入。
        inputs = left.get_data() + right.get_data()
        inputs.append(0.5) # the bias

        net.Input(inputs)

inputs数组以左侧数据开始,然后继续添加右侧数据。之后,将偏差值附加到inputs数组的末尾,并将数组数据作为输入提供给检测器ANN。

  1. 在检测器ANN激活特定次数之后,获得输出并四舍五入。
        outputs = net.Output()
        outputs[0] = 1.0 if outputs[0] >= 0.5 else 0.0
        outputs[1] = 1.0 if outputs[1] >= 0.5 else 0.0
  1. 接下来,我们需要计算*方检测错误,这是输出向量与真实值向量之间的欧几里得距离。因此,我们首先创建以下具有真实值的向量:
        left_target = 1.0 if left.side == Side.LEFT or \
                             left.side == Side.BOTH else 0.0
        right_target = 1.0 if right.side == Side.RIGHT or \
                              right.side  == Side.BOTH else 0.0
        targets = [left_target, right_target]

如果视觉对象对于视网膜的给定侧面或两侧都是有效的,则将相应的真实值设置为1.0。否则,将其设置为0.0以指示视觉对象位置不正确。

  1. 最后,计算*方检测错误如下:
    error = (outputs[0]-targets[0]) * (outputs[0]-targets[0]) + \
            (outputs[1]-targets[1]) * (outputs[1]-targets[1])

该函数返回检测错误和检测器ANN的输出。在下一节中,我们将讨论视网膜实验运行器的实现。

对于完整的实现细节,请参阅https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter8/retina_environment.py中的retina_environment.py文件。

实验运行器

为了解决模块化视网膜问题,我们需要使用一个提供ES-HyperNEAT算法实现的Python库。如果你已经阅读了上一章,你应该已经熟悉了MultiNEAT Python库,它也实现了ES-HyperNEAT算法。因此,我们可以使用这个库来创建视网膜实验运行器的实现。

让我们讨论实现中的基本组件。

对于完整的实现细节,请参阅https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter8/retina_experiment.py中的retina_experiment.py文件。

实验运行器函数

run_experiment函数使用提供的超参数和一个初始化的测试环境来运行实验,评估发现的检测器ANN相对于可能的视网膜配置。函数实现具有以下显著部分:

  1. 首先是初始化初始CPPN基因组的种群:
    seed = 1569777981
    # Create substrate
    substrate = create_substrate()
    # Create CPPN genome and population
    g = NEAT.Genome(0,
             substrate.GetMinCPPNInputs(),
             2, # hidden units
             substrate.GetMinCPPNOutputs(),
             False,
             NEAT.ActivationFunction.TANH,
             NEAT.ActivationFunction.SIGNED_GAUSS, # hidden 
             1, # hidden layers seed
             params, 
             1) # one hidden layer
    pop = NEAT.Population(g, params, True, 1.0, seed)
    pop.RNG.Seed(seed)

首先,前面的代码将随机种子值设置为我们在通过顺序运行许多实验试验以生成成功解决方案时发现的有用值。之后,我们创建适合视网膜实验的底物配置,考虑到视网膜空间的几何形状。

接下来,我们使用已有的底物配置创建初始CPPN基因组。CPPN基因组需要具有与底物配置兼容的输入和输出节点数。此外,我们使用具有高斯激活函数的两个隐藏节点初始化初始CPPN基因组,以正确方向促进神经进化过程。高斯隐藏节点以偏向产生特定检测器ANN拓扑结构的方式开始神经进化搜索。通过这些隐藏节点,我们将对称性原则引入底物的连接模式中,这正是我们期望在成功检测器ANN拓扑结构中实现的。对于视网膜问题,我们需要发现一个包含两个对称分类模块的对称检测器ANN配置。

  1. 接下来,我们准备中间变量来保存实验执行结果以及统计收集器。之后,我们运行进化循环,进行一定数量的代数:
    start_time = time.time()
    best_genome_ser = None
    best_ever_goal_fitness = 0
    best_id = -1
    solution_found = False

    stats = Statistics()
    ...
  1. 在进化循环内部,我们获取当前种群中属于当前种群的基因组列表,并按照以下方式对其进行测试环境评估:
        # get list of current genomes
        genomes = NEAT.GetGenomeList(pop)

        # evaluate genomes
        genome, fitness, errors = eval_genomes(genomes, 
                         rt_environment=rt_environment, 
                         substrate=substrate, 
                         params=params)
        stats.post_evaluate(max_fitness=fitness, errors=errors)
        solution_found = fitness >= FITNESS_THRESHOLD

eval_genomes函数返回一个元组,包含以下组件:最佳拟合基因组、所有评估基因组的最高适应度分数以及每个评估基因组的检测错误列表。我们将适当的参数保存到统计收集器中,并将获得的适应度分数与搜索终止标准进行比较,该标准定义为FITNESS_THRESHOLD常量,其值为1000.0。如果种群中的最佳适应度分数大于或等于FITNESS_THRESHOLD值,进化搜索将成功终止。

  1. 如果找到了成功的解决方案,或者当前种群的最佳适应度分数高于之前达到的最高适应度分数,我们将按照以下方式保存最佳CPPN基因组和当前适应度分数:
        if solution_found or best_ever_goal_fitness < fitness:
            # dump to pickle to freeze the genome state
            best_genome_ser = pickle.dumps(genome)
            best_ever_goal_fitness = fitness
            best_id = genome.GetID()
  1. 之后,如果solution_found变量的值被设置为True,我们将终止进化循环:
        if solution_found:
            print('Solution found at generation: %d, best fitness: %f, species count: %d' % (generation, fitness, len(pop.Species)))
            break
  1. 如果进化未能产生成功的解决方案,我们将打印当前代的统计数据,并移动到下一个时代:
        # advance to the next generation
        pop.Epoch()

        # print statistics
        gen_elapsed_time = time.time() - gen_time
        print("Best fitness: %f, genome ID: %d" % 
               (fitness, best_id))
        print("Species count: %d" % len(pop.Species))
        print("Generation elapsed time: %.3f sec" % 
               (gen_elapsed_time))
        print("Best fitness ever: %f, genome ID: %d" % 
               (best_ever_goal_fitness, best_id))

实验运行器代码的其余部分以不同的格式报告实验结果。

  1. 我们使用进化循环中收集的统计数据,以文本和视觉格式报告实验结果。此外,可视化结果也以SVG矢量格式保存到本地文件系统中:
    print("\nBest ever fitness: %f, genome ID: %d" % 
             (best_ever_goal_fitness, best_id))
    print("\nTrial elapsed time: %.3f sec" % (elapsed_time))
    print("Random seed:", seed)

代码的前三行打印了关于实验执行的通用统计数据,例如达到的最高适应度分数、实验执行所花费的时间以及随机生成器的种子值。

  1. 代码的下一部分是关于可视化实验结果,这是最有信息量的部分,你应该特别注意。我们从可视化在进化过程中找到的最佳基因组创建的CPPN网络开始:
    if save_results or show_results:        
        # Draw CPPN network graph
        net = NEAT.NeuralNetwork()
        best_genome.BuildPhenotype(net)
        visualize.draw_net(net, view=False, node_names=None, 
                           filename="cppn_graph.svg", 
                           directory=trial_out_dir, fmt='svg')
        print("\nCPPN nodes: %d, connections: %d" % 
                     (len(net.neurons), len(net.connections)))
  1. 之后,我们可视化使用最佳CPPN基因组和视网膜基板创建的检测器ANN拓扑结构:
        net = NEAT.NeuralNetwork()
        best_genome.BuildESHyperNEATPhenotype(net, substrate, 
                                              params)
        visualize.draw_net(net, view=False, node_names=None, 
                           filename="substrate_graph.svg", 
                           directory=trial_out_dir, fmt='svg')
        print("\nSubstrate nodes: %d, connections: %d" % 
                 (len(net.neurons), 
               len(net.connections)))
        inputs = net.NumInputs()
        outputs = net.NumOutputs()
        hidden = len(net.neurons) - net.NumInputs() - \
                 net.NumOutputs()
        print("\n\tinputs: %d, outputs: %d, hidden: %d" % 
                (inputs, outputs, hidden))
  1. 此外,我们还打印了前述代码创建的检测器ANN对完整数据集和两个随机选择的视觉对象的评估结果:
        # Test against random retina configuration
        l_index = random.randint(0, 15)
        r_index = random.randint(0, 15)
        left = rt_environment.visual_objects[l_index]
        right = rt_environment.visual_objects[r_index]
        err, outputs = rt_environment._evaluate(net, left, 
                                                right, 3)
        print("Test evaluation error: %f" % err)
        print("Left flag: %f, pattern: %s" % (outputs[0], left))
        print("Right flag: %f, pattern: %s" % (outputs[1], right))

        # Test against all visual objects
        fitness, avg_error, total_count, false_detections = \
                     rt_environment.evaluate_net(net, debug=True)
        print("Test evaluation against full data set [%d], fitness: %f, average error: %f, false detections: %f" % (total_count, fitness, avg_error, false_detections))

最后,我们将实验期间收集的统计数据以如下方式呈现:

        # Visualize statistics
        visualize.plot_stats(stats, ylog=False, view=show_results, 
              filename=os.path.join(trial_out_dir,            ‘avg_fitness.svg’))

这里提到的所有可视化图表都可以在本地文件系统的 trial_out_dir 目录中实验执行后找到。现在,让我们讨论基板构建函数的实现。

基板构建函数

ES-HyperNEAT方法运行神经进化过程,这包括CPPN基因组的进化以及基板配置的进化。然而,尽管基板在进化过程中也在进化,但从一个适当的初始基板配置开始是非常有益的。这个配置应该对应于问题空间的几何形状。

对于视网膜实验,适当的基板配置创建如下:

  1. 首先,我们创建基板输入层的配置。如您在“初始基板配置”部分所记得,输入层的八个节点放置在XZ*面内,该*面垂直于XY*面。此外,为了反映视网膜空间的几何形状,左侧对象的节点需要放置在*面的左侧,右侧对象的节点相应地放置在*面的右侧。偏置节点应位于输入节点*面的中心。因此,输入层创建如下:
    # The input layer
    x_space = np.linspace(-1.0, 1.0, num=4)
    inputs = [
        # the left side
        (x_space[0], 0.0, 1.0), (x_space[1], 0.0, 1.0), 
        (x_space[0], 0.0, -1.0), (x_space[1], 0.0, -1.0),
        # the right side
        (x_space[2], 0.0, 1.0), (x_space[3], 0.0, 1.0), 
        (x_space[2], 0.0, -1.0), (x_space[3], 0.0, -1.0), 
        (0,0,0) # the bias
        ]

两个输出节点位于XY*面内,该*面垂直于输入*面。这种基板配置通过将发现的隐藏节点放置在XY*面内,允许基板自然进化。

  1. 输出层创建如下:
        # The output layer
        outputs = [(-1.0, 1.0, 0.0), (1.0, 1.0, 0.0)]
  1. 接下来,我们定义基板的一般配置参数如下:
    # Allow connections: input-to-hidden, hidden-to-output, 
    # and  hidden-to- hidden
    substrate.m_allow_input_hidden_links = True
    substrate.m_allow_hidden_output_links = True
    substrate.m_allow_hidden_hidden_links = True

    substrate.m_allow_input_output_links = False
    substrate.m_allow_output_hidden_links = False
    substrate.m_allow_output_output_links = False
    substrate.m_allow_looped_hidden_links = False
    substrate.m_allow_looped_output_links = False

    substrate.m_hidden_nodes_activation = \
           NEAT.ActivationFunction.SIGNED_SIGMOID
    substrate.m_output_nodes_activation = \
           NEAT.ActivationFunction.UNSIGNED_SIGMOID

    # send connection length to the CPPN as a parameter
    substrate.m_with_distance = True 
    substrate.m_max_weight_and_bias = 8.0

我们允许基板从输入到隐藏层、隐藏层到隐藏层以及隐藏层到输出节点之间有连接。我们指定隐藏节点应使用带符号的Sigmoid激活函数,而输出节点应使用无符号的Sigmoid激活函数。我们选择无符号的Sigmoid激活函数用于输出节点,以便检测器ANN的输出值在范围 [0,1] 内。

在下一节中,我们将讨论评估解决方案适应性的函数实现。

适应性评估

神经进化过程需要一种方法来评估每一代进化中基因组群体的适应性。在我们的实验中,适应性评估包括两个部分,我们在这里进行讨论。

eval_genomes 函数

此函数评估整体群体的适应性。它具有以下定义:

def eval_genomes(genomes, substrate, rt_environment, params):
    best_genome = None
    max_fitness = 0
    errors = []
    for genome in genomes:
        fitness, error, total_count, false_detetctions = eval_individual(
                               genome, substrate, rt_environment, params)
        genome.SetFitness(fitness)
        errors.append(error)

        if fitness > max_fitness:
            max_fitness = fitness
            best_genome = genome

    return best_genome, max_fitness, errors

eval_genomes函数接受当前种群中的CPPN基因组列表、底物配置、初始化的测试环境和ES-HyperNEAT超参数作为参数。

在代码的开始部分,我们创建一个中间对象来收集每个特定基因组的评估结果:

    best_genome = None
    max_fitness = 0
    errors = []

之后,我们开始循环遍历所有基因组,并对每个基因组进行给定测试环境的评估:

    for genome in genomes:
        fitness, error, total_count, false_detetctions = eval_individual(
                               genome, substrate, rt_environment, params)
        genome.SetFitness(fitness)
        errors.append(error)

        if fitness > max_fitness:
            max_fitness = fitness
            best_genome = genome

最后,函数返回一个元组,其中包含最佳基因组、最高适应度分数以及每个评估基因组的所有检测错误列表。

eval_individual函数

此函数评估每个个体的适应度,其定义如下:

def eval_individual(genome, substrate, rt_environment, params):
    # Create ANN from provided CPPN genome and substrate
    net = NEAT.NeuralNetwork()
    genome.BuildESHyperNEATPhenotype(net, substrate, params)

    fitness, dist, total_count, false_detetctions = \
       rt_environment.evaluate_net(net, max_fitness=MAX_FITNESS)
    return fitness, dist, total_count, false_detetctions

它接受要评估的CPPN基因组、底物配置、测试环境和ES-HyperNEAT超参数作为参数。使用提供的参数,我们创建检测器ANN的神经网络配置,并对其在给定的测试环境中进行评估。然后,该函数返回评估结果。

模块化视网膜实验

现在,我们准备开始针对模拟模块化视网膜问题空间的测试环境进行实验。在接下来的小节中,你将了解如何选择合适的超参数以及如何设置环境和运行实验。之后,我们将讨论实验结果。

超参数选择

超参数定义为Parameters Python类,MultiNEAT库引用它以获取必要的配置选项。在实验运行脚本源代码中,我们定义了一个名为create_hyperparameters的专用函数,它封装了超参数初始化的逻辑。以下,我们将描述最关键的超参数以及选择这些特定值的原因:

  1. 我们决定使用中等大小的CPPN基因组种群。这样做是为了通过从一开始就提供大量解决方案搜索选项来增强进化。种群的大小定义如下:
    params.PopulationSize = 300
  1. 接下来,我们在[5,15]范围内定义在进化过程中要保留的物种数量,并将物种停滞设置为100代。这种配置使我们能够在物种之间保持健康的多样性,并让它们存活足够长的时间,以产生我们正在寻找的解决方案:
    params.SpeciesMaxStagnation = 100
    params.MinSpecies = 5
    params.MaxSpecies = 15
  1. 我们对生成一个非常紧凑的CPPN基因组配置感兴趣。因此,我们为控制新节点和连接在基因组中引入的频率设置了非常小的概率值:
    params.MutateAddLinkProb = 0.03
    params.MutateAddNeuronProb = 0.03
  1. ES-HyperNEAT方法是HyperNEAT方法的一个扩展。因此,在进化过程中,它会改变隐藏和输出节点中激活函数的类型。在这个实验中,为了产生适当的底物配置,我们对以下激活类型感兴趣,这些类型以相等的概率被选中:
    params.ActivationFunction_SignedGauss_Prob = 1.0
    params.ActivationFunction_SignedStep_Prob = 1.0
    params.ActivationFunction_Linear_Prob = 1.0
    params.ActivationFunction_SignedSine_Prob = 1.0
    params.ActivationFunction_SignedSigmoid_Prob = 1.0
  1. 最后,我们定义了 ES-HyperNEAT 特定的超参数,这些参数控制着基质的进化方式。以下超参数控制着在进化过程中,基质内节点和连接创建的动态:
    params.DivisionThreshold = 0.5
    params.VarianceThreshold = 0.03

params.DivisionThreshold 控制在每一代进化中引入基质的新节点和连接的数量。params.VarianceThreshold 确定在修剪和提取阶段后允许保留在基质中的节点和连接的数量。有关这些阈值的更多详细信息,请参阅 Quadtree 信息提取和 ES-HyperNEAT 基础 部分。

工作环境设置

在这个实验中,我们使用提供 ES-HyperNEAT 算法实现的 MultiNEAT Python 库。因此,我们需要创建一个适当的 Python 环境,其中包括 MultiNEAT Python 库和所有必要的依赖项。这可以通过在命令行中执行以下命令来完成:

$ conda create --name rt_multineat python=3.5
$ conda activate vd_multineat
$ conda install -c conda-forge multineat 
$ conda install matplotlib
$ conda install -c anaconda seaborn
$ conda install graphviz
$ conda install python-graphviz

这些命令创建并激活了 Python 3.5 的 rt_multineat 虚拟环境。之后,它们安装了最新版本的 MultiNEAT Python 库,以及我们代码用于结果可视化的依赖项。

运行模块化视网膜实验

在这个阶段,我们已经在 retina_experiment.py Python 脚本中完全定义了实验运行脚本。你可以通过克隆相应的 Git 仓库并运行以下命令来开始实验:

$ git clone https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python.git
$ cd Hands-on-Neuroevolution-with-Python/Chapter8
$ python retina_experiment.py -t 1 -g 1000

不要忘记使用以下命令激活适当的虚拟环境:

conda activate rt_multineat

前面的命令开始了一个实验,该实验进行了 1,000 代进化的试验。在特定的代数之后,应该找到成功的解决方案,你将在控制台看到以下输出:

****** Generation: 949 ******

Solution found at generation: 949, best fitness: 1000.000000, species count: 6

Best ever fitness: 1000.000000, genome ID: 284698

Trial elapsed time: 1332.576 sec
Random seed: 1569777981

CPPN nodes: 21, connections: 22

Substrate nodes: 15, connections: 28

如你在输出中看到的那样,成功的解决方案是在第 949 代找到的。它是由一个具有 21 个节点和 22 个节点之间连接的 CPPN 基因组产生的。同时,确定检测器 ANN 拓扑结构的基质有 15 个节点和它们之间的 28 个连接。成功的解决方案是使用随机种子值 1569777981 产生的。使用其他随机种子值可能无法产生成功的解决方案,或者它将需要更多代的进化。

接下来,观察进化过程中的*均适应度和误差的图表是很有趣的:

每代的*均适应度和误差

你可以在前面的图表中看到,在大多数进化代数中,适应度得分非常小(大约为 20),但突然,找到了成功的 CPPN 基因组,它仅在一代内产生了立即的进化飞跃。

成功的 CPPN 基因组的配置如下所示:

成功基因的CPPN表型图

该图非常有趣,因为,正如你所看到的,成功的CPPN基因配置没有使用所有可用的输入(灰色方块)来产生输出。更重要的是,它甚至更加令人困惑,因为它在决定是否在这些基底层节点之间暴露连接时,只使用了输入(节点#0)的x坐标和隐藏(节点#3)基底层节点的y坐标。同时,基底层输出节点的xy坐标都参与了决策过程(节点#4和#5)。

当你查看我们之前提出的初始基底层配置时,你会发现我们提到的特性完全由基底层拓扑结构证实。我们将输入节点放置在XZ*面内。因此,对于它们来说,y坐标根本不重要。同时,位于XY*面内的隐藏节点,y坐标决定了从输入*面到节点的距离。最后,输出节点也位于XY*面内。它们的x坐标决定了每个输出节点相关的视网膜侧面。因此,对于输出节点来说,自然地,xy坐标都包含在内。

在CPPN表型图中,输入节点用方块标记,输出节点用实心圆圈标记,偏置节点用菱形标记,隐藏节点用空心圆圈标记。

CPPN图中两个输出节点有以下含义:

  • 第一个节点(8)提供了连接的权重。

  • 第二个节点(9)确定连接是否表达。

CPPN的输入节点定义为以下:

  • 前两个节点(0和1)设置了基底层输入层中的点坐标(xz)。

  • 接下来的两个节点(2和3)设置了基底层隐藏层中的点坐标(xy)。

  • 接下来的两个节点(4和5)设置了基底层输出层中的点坐标(xy)。

  • 最后一个节点(6)设置了输入层中点与坐标原点的欧几里得距离。

然而,你可以在下面的图中看到实验结果中最激动人心的部分。它代表了成功检测器ANN的配置:

图片

检测器ANN的配置

与之前的图表一样,我们用方块标记输入节点,用实心圆圈标记输出节点,用菱形标记偏置节点,用空心圆圈标记隐藏节点。

正如你所见,我们在图的左右两侧有两个明显分离的模块化结构。每个模块都与视网膜左侧(节点#0、#1、#2和#3)和右侧(节点#4、#5、#6和#7)的相应输入相连。两个模块具有相同数量的隐藏节点,这些节点连接到相应的输出节点:视网膜左侧的节点#9和右侧的节点#10。你还可以看到左右模块的连接模式相似。左侧的隐藏节点#11具有与右侧节点#14相似的连接模式,同样,节点#12和#13也是如此。

真是令人惊叹,随机进化过程能够发现如此简单而优雅的解决方案。通过这个实验的结果,我们完全证实了我们的假设,即视网膜问题可以通过创建模块化检测ANN拓扑结构来解决。

更多关于模块化视网膜问题的详细信息可以在原始论文http://eplex.cs.ucf.edu/papers/risi_alife12.pdf中找到。

练习

  1. 尝试运行一个实验,改变retina_experiment.py脚本中第101行可以更改的随机种子生成器的不同值。看看你是否可以用其他值找到成功的解决方案。

  2. 尝试通过调整params.PopulationSize超参数的值将初始种群大小增加到1,000。这如何影响了算法的性能?

  3. 尝试通过将选择概率设置为0来改变在进化过程中使用的激活函数类型的数量。当排除ActivationFunction_SignedGauss_ProbActivationFunction_SignedStep_Prob激活类型时,观察会发生什么特别有趣。

摘要

在本章中,我们学习了神经进化方法,该方法允许在寻找问题解决方案的过程中使底物配置进化。这种方法使人类设计师从创建适合的最小细节的底物配置的负担中解放出来,使我们能够仅定义主要轮廓。算法将在进化过程中自动学习底物配置的剩余细节。

此外,你学习了可以使用模块化人工神经网络结构来解决各种问题,包括模块化视网膜问题。模块化ANN拓扑结构是一个非常强大的概念,它允许多次重用成功的表型ANN模块来构建复杂的分层拓扑。此外,你还有机会通过使用MultiNEAT Python库实现相应的解决方案来磨练你的Python编程技能。

在下一章中,我们将讨论协同进化的迷人概念以及如何将其用于同时协同进化用于优化的求解器和目标函数。我们将讨论解决方案和适应度演化的方法,并教会你如何将其应用于修改后的迷宫求解实验。

第十二章:协同进化和SAFE方法

在本章中,我们介绍了协同进化的概念,并解释了如何使用它来协同进化求解器和优化求解器进化的目标函数。然后,我们讨论了解决方案和适应度进化SAFE)方法,并简要概述了不同的协同进化策略。您将学习如何使用基于神经进化的方法进行协同进化。您还将获得修改迷宫求解实验的实践经验。

本章将涵盖以下主题:

  • 协同进化基础和常见协同进化策略

  • SAFE方法基础

  • 修改后的迷宫求解实验

  • 关于实验结果的讨论

技术要求

为了执行本章中描述的实验,应满足以下技术要求:

  • Windows 8/10,macOS 10.13或更高版本,或现代Linux

  • Anaconda Distribution版本2019.03或更高版本

本章的代码可以在https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/tree/master/Chapter9找到。

常见的协同进化策略

生物系统的自然进化不能与协同进化的概念分开考虑。协同进化是导致当前生物圈状态的中央进化驱动力之一,其中包括我们周围可感知的有机体的多样性。

我们可以将协同进化定义为多个不同生物系谱同时进化的互利策略。一个物种的进化不可能在没有其他物种的情况下进行。在进化过程中,协同进化的物种相互互动,这些物种间的关系塑造了它们的进化策略。

存在三种主要的协同进化类型:

  • 互利共生是指两种或更多物种共存并相互受益。

  • 竞争性协同进化

    • 捕食是指一个生物体杀死另一个生物体并消耗其资源。

    • 寄生是指一个生物体利用另一个生物体的资源,但不会杀死它。

  • 共生是指一种物种的成员从另一种物种中受益,而不对另一种物种造成伤害或利益。

研究人员已经探索了每种协同进化策略,它们作为神经进化过程的指导原则各有优缺点。然而,最*有一组研究人员探索了共生策略作为神经进化的指导原则,并取得了有希望的结果。他们创建了SAFE算法,我们将在本章中讨论。

关于SAFE算法的更多细节,请参阅原始出版物https://doi.org/10.1007/978-3-030-16670-0_10

现在我们已经涵盖了常见的协同进化类型,让我们详细讨论SAFE方法。

SAFE方法

如其名所示,SAFE方法涉及解决方案和适应度函数的协同进化,这引导了解决方案搜索优化。SAFE方法围绕两个种群之间的共生协同进化策略构建:

  • 那些进化以解决当前问题的潜在解决方案种群

  • 那些进化以引导解决方案种群进化的目标函数候选种群

在这本书中,我们已经讨论了几种可以用来指导潜在解决方案进化过程的搜索优化策略。这些策略是基于目标函数的适应度优化和新颖搜索优化。前一种优化策略在适应度函数景观简单的情况下非常完美,我们可以将优化搜索集中在最终目标上。在这种情况下,我们可以使用基于目标的度量标准,它评估在每个进化时代,我们的当前解决方案与目标有多接*。

新颖搜索优化策略是不同的。在这个策略中,我们并不关心候选解与最终目标的接*程度,而是主要关注候选解所采取的路径。新颖搜索方法背后的核心思想是逐步探索垫脚石,最终引导到目的地。这种优化策略非常适合我们面临的是一个复杂度高的适应度函数景观,其中有许多误导性的死胡同和局部最优解的情况。

因此,SAFE方法背后的主要思想是利用这里提到的两种搜索优化方法的优势。接下来,我们将讨论修改后的迷宫实验,该实验使用这里提到的两种搜索优化方法来指导神经进化过程。

修改后的迷宫实验

我们已经在本书中讨论了如何将基于目标的搜索优化或新颖搜索优化方法应用于解决迷宫的问题。在本章中,我们介绍了一种修改后的迷宫解决实验,我们尝试使用SAFE算法结合这两种搜索优化方法。

我们介绍了两个种群之间的协同进化:一个是迷宫解决代理种群,另一个是目标函数候选种群。遵循SAFE方法,我们在实验中采用了一种共生协同进化策略。让我们首先讨论迷宫解决代理。

迷宫解决代理

迷宫解决代理配备了一套传感器,使其能够感知迷宫环境,并在每一步知道迷宫出口的方向。传感器的配置如下所示:

图片

迷宫解决代理的传感器配置

在前面的图中,暗箭头定义了测距传感器的作用范围,允许代理感知障碍物并找到给定方向上的障碍物距离。围绕机器人身体的四个区域是扇形雷达,它们在每个时间步检测迷宫出口的方向。机器人身体内部的浅箭头确定机器人面向的方向。

此外,机器人有两个执行器:一个用于改变其角速度(旋转),另一个用于改变其线性速度。

我们使用与第5章自主迷宫导航中相同的机器人配置。因此,您应该参考该章节以获取更多详细信息。现在我们已经涵盖了迷宫求解代理,让我们来看看迷宫环境。

迷宫环境

迷宫被定义为由外部墙壁包围的区域。在迷宫内部,多个内部墙壁创建了多个局部适应度最优的死胡同,这使得以目标为导向的优化搜索不太有效。此外,由于局部适应度最优值,基于目标的搜索代理可能会陷入特定的死胡同,完全停止进化过程。死胡同在以下图中显示:

图片

迷宫中的局部最优区域

在前面的图中,求解代理的起始位置用左下角的实心圆圈标记,迷宫出口用左上角的实心圆圈标记。欺骗性的局部适应度最优值以实心扇区形式显示在代理的起始位置。

迷宫环境通过配置文件定义,我们已经实现了模拟器来模拟求解代理在迷宫中的遍历。我们已在第5章自主迷宫导航中讨论了迷宫模拟器环境的实现,您可以参考该章节以获取详细信息。

在本章中,我们讨论了引入到原始实验中的修改,以实现SAFE优化策略。最关键的区别在于适应度函数的定义,我们将在下一节中讨论。

您可以在源代码中查看迷宫模拟器环境的完整实现细节,网址为https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter9/maze_environment.py

适应度函数定义

SAFE方法涉及解决方案候选者和目标函数候选者的共同进化,也就是说,我们有两种共同进化的种群。因此,我们需要定义两个适应度函数:一个用于解决方案候选者(迷宫求解器),另一个用于目标函数候选者。在本节中,我们讨论了这两种变体。

迷宫求解器的适应度函数

在进化的每一代中,每个解决方案个体(迷宫求解器)都会与所有目标函数候选者进行评估。我们使用在评估迷宫求解器与每个目标函数候选者时获得的最高适应度得分作为解决方案的适应度得分。

迷宫求解器的适应度函数是两个指标的总和——迷宫出口的距离(基于目标的得分)和求解器最终位置的新颖性(新颖性得分)。这些得分通过从目标函数候选者的特定个体中获得的系数对进行算术组合。

以下公式给出了这些得分的组合作为适应度得分:

是通过评估解决方案候选者 对目标函数 的评估获得的适应度值。所使用的系数对 是特定目标函数候选者的输出。这对系数决定了迷宫出口 () 和解决方案的行为新颖性 () 如何影响迷宫求解器在轨迹末尾的最终适应度得分。

迷宫出口的距离 () 被确定为迷宫求解器的最终坐标和迷宫出口坐标之间的欧几里得距离。这如下公式所示:

是迷宫求解器的最终坐标,而 是迷宫出口的坐标。

每个迷宫求解器的创新得分,,由其在迷宫中的最终位置(点 )决定。它被计算为从这个点到最*的k个邻居点的*均距离,这些邻居点的位置是其他迷宫求解器的最终位置。

以下公式给出了行为空间中点 x 处的创新得分值:

的第i个最*邻, 之间的距离。

两点之间的距离是新颖度度量,衡量当前解决方案()与由不同迷宫求解者产生的另一个()之间的差异。新颖度度量是两点之间的欧几里得距离:

 和  分别是坐标向量中持有  和  点坐标的位置  的值。

接下来,我们将讨论如何定义目标函数候选者优化的适应度函数。

目标函数候选者的适应度函数

SAFE方法基于一种互利共生协同进化方法,这意味着在进化过程中,其中一个协同进化的种群既不受益也不受损。在我们的实验中,互利共生的种群是目标函数候选者的种群。对于这个种群,我们需要定义一个与迷宫求解者种群性能无关的适应度函数。

这样的函数候选者是一个使用新颖度评分作为要优化的适应度评分的适应度函数。计算每个目标函数候选者新颖度评分的公式与迷宫求解者给出的相同。唯一的区别是,在目标函数候选者的案例中,我们使用每个个体的输出值向量来计算新颖度评分。之后,我们使用新颖度评分值作为个体的适应度评分。

这种新颖度评分估计方法是改进的新颖度搜索NS)方法的一部分,我们将在下一节中讨论。

改进的Novelty Search

我们在第6章,《新颖度搜索优化方法》中介绍了NS方法。在当前实验中,我们使用NS方法的一个略微修改版本,我们将在下一节中讨论。

我们将在本次实验中提出的对NS方法的修改与维护新颖度点存档的新方法有关。新颖度点持有迷宫求解者在轨迹末尾在迷宫中的位置,并将其与新颖度评分相结合。

在NS方法的更传统版本中,新颖存档的大小是动态的,如果新颖度得分超过某个阈值(新颖度阈值),则允许添加特定的创新点。此外,新颖度阈值可以在运行时进行调整,考虑到在进化过程中新新颖点的发现速度。这些调整使我们能够控制存档的最大大小(在一定程度上)。然而,我们需要从一个初始新颖度阈值值开始,这个选择并不明显。

修改后的NS方法引入了固定大小的创新存档来解决选择正确新颖度阈值值的问题。新的新颖点被添加到存档中,直到它填满。之后,只有当新颖度得分超过存档当前最小得分时,才会将新颖点添加到存档中,通过用具有最小得分的当前点替换它。这样,我们可以保持新颖存档的固定大小,并在其中仅存储在进化过程中发现的最有价值的新颖点。

修改后的新颖存档实现的源代码可以在https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter9/novelty_archive.py找到。

接下来,让我们讨论实现中最有趣的部分。

_add_novelty_item 函数

此功能允许在保持其大小的同时向存档中添加新的新颖点。其实现如下:

        if len(self.novel_items) >= MAXNoveltyArchiveSize:
            # check if this item has higher novelty than  
            # last item in the archive (minimal novelty)
            if item > self.novel_items[-1]:
                # replace it
                self.novel_items[-1] = item
        else:
            # just add new item
            self.novel_items.append(item)

        # sort items array in descending order by novelty score
        self.novel_items.sort(reverse=True)

代码首先检查新颖存档的大小是否尚未超过,在这种情况下直接将新的新颖点附加到其中。否则,一个新的新颖点将替换存档中的最后一个项目,即具有最小新颖度得分的项目。我们可以确信存档中的最后一个项目具有最小的新颖度得分,因为在我们将新项目添加到存档后,我们按新颖度得分值降序排序。

evaluate_novelty_score 函数

此函数提供了一种机制来评估新颖项目相对于已收集在新颖存档中的所有项目以及当前种群中发现的全部新颖项目的创新度得分。我们按照以下步骤计算新颖度得分,作为到 k=15 个最*邻的*均距离:

  1. 我们需要收集提供的创新项目与新颖存档中所有项目之间的距离:
        distances = []
        for n in self.novel_items:
            if n.genomeId != item.genomeId:
                distances.append(self.novelty_metric(n, item))
            else:
                print("Novelty Item is already in archive: %d" % 
                       n.genomeId)
  1. 之后,我们将提供的创新项目与当前种群中的所有项目之间的距离添加到其中:
        for p_item in n_items_list:
            if p_item.genomeId != item.genomeId:
                distances.append(self.novelty_metric(p_item, item))
  1. 最后,我们可以估计*均k-最*邻值:
        distances = sorted(distances) 
        item.novelty = sum(distances[:KNN])/KNN

我们将列表按距离升序排序,以确保最*的项首先出现在列表中。然后,我们计算列表中前k=15项的总和,并将其除以总和值的数量。因此,我们得到到k-最*邻的*均距离值。

修改后的NS优化方法是迷宫求解者种群和目标函数候选者种群适应度评分评估的核心。我们在实验运行器的实现中广泛使用它,我们将在下一节中讨论。

修改后的迷宫实验实现

实验运行器的实现基于MultiNEAT Python库,我们在本书的几个实验中使用了该库。每个协同进化种群的进化由基本NEAT算法控制,该算法在第3章使用NEAT进行XOR求解器优化第4章杆*衡实验,和第5章自主迷宫导航中进行了讨论。

然而,在本节中,我们展示了如何使用NEAT算法来维持两个独立物种种群(迷宫求解器和目标函数候选者)的协同进化。

接下来,我们讨论修改后的迷宫实验运行器的关键部分。

更多细节,请参阅https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter9/maze_experiment_safe.py的源代码。

协同进化种群的创建

在这个实验中,我们需要创建两个具有不同初始基因型配置的协同进化的物种种群,以满足产生物种的表型需求。

迷宫求解器的表型有11个输入节点来接收来自传感器的信号,以及两个输出节点来产生控制信号。同时,目标函数候选者的表型有一个输入节点接收固定值(0.5),该值被转换为两个输出值,用作迷宫求解器的适应度函数系数。

我们首先讨论如何创建目标函数候选者种群。

目标函数候选者种群的创建

编码目标函数候选者表型的基因型必须产生具有至少一个输入节点和两个输出节点的表型配置,正如之前所讨论的。我们在create_objective_fun函数中实现种群创建如下:

    params = create_objective_fun_params()
    # Genome has one input (0.5) and two outputs (a and b)
    genome = NEAT.Genome(0, 1, 1, 2, False, 
        NEAT.ActivationFunction.TANH, # hidden layer activation
        NEAT.ActivationFunction.UNSIGNED_SIGMOID, # output layer activation
        1, params, 0)
    pop = NEAT.Population(genome, params, True, 1.0, seed)
    pop.RNG.Seed(seed)

    obj_archive = archive.NoveltyArchive(
                             metric=maze.maze_novelty_metric_euclidean)
    obj_fun = ObjectiveFun(archive=obj_archive, 
                             genome=genome, population=pop)

在此代码中,我们创建了一个具有一个输入节点、两个输出节点和一个隐藏节点的NEAT基因型。隐藏节点被预先种入初始基因组中以增强进化过程中的预定义非线性。隐藏层的激活函数类型被选为双曲正切,以支持负输出值。这一特性对于我们的任务至关重要。目标函数候选者产生的系数之一为负值可以表明迷宫求解代理适应性函数的特定组成部分具有负面影响,这会发出进化需要尝试其他路径的信号。

最后,我们创建ObjectiveFun对象来维护目标函数候选者的进化群体。

接下来,我们将讨论迷宫求解代理群体的创建方法。

创建迷宫求解代理的群体

迷宫求解代理需要从11个传感器获取输入并生成两个控制信号,这些信号影响机器人的角速度和线速度。因此,编码迷宫求解代理表型的基因组必须产生包含11个输入节点和两个输出节点的表型配置。您可以通过查看create_robot函数来了解迷宫求解代理初始基因组群体的创建过程:

    params = create_robot_params()
    # Genome has 11 inputs and two outputs
    genome = NEAT.Genome(0, 11, 0, 2, False, 
                        NEAT.ActivationFunction.UNSIGNED_SIGMOID, 
                        NEAT.ActivationFunction.UNSIGNED_SIGMOID, 
                        0, params, 0)
    pop = NEAT.Population(genome, params, True, 1.0, seed)
    pop.RNG.Seed(seed)

    robot_archive = archive.NoveltyArchive(metric=maze.maze_novelty_metric)
    robot = Robot(maze_env=maze_env, archive=robot_archive, genome=genome, 
                  population=pop)

在代码中,我们从create_robot_params函数中获取适当的NEAT超参数。之后,我们使用它们来创建具有相应数量输入和输出节点的初始NEAT基因型。最后,我们创建一个Robot对象,它封装了与迷宫求解代理群体相关的所有数据,以及迷宫模拟环境。

现在,当我们创建了两个协同进化的群体后,我们需要实现两个群体中个体的适应性分数评估。我们将在下一节中讨论适应性分数评估的实现细节。

协同进化群体的适应性评估

已经定义了两个协同进化的群体后,我们需要创建函数来评估每个群体中个体的适应性分数。正如我们之前提到的,迷宫求解代理群体中个体的适应性分数取决于目标函数候选者群体产生的输出。同时,每个目标函数候选者的适应性分数完全由该个体的新颖性分数决定。

因此,我们有两种不同的方法来评估适应性分数,我们需要实现两个不同的函数。以下我们将讨论这两种实现方法。

目标函数候选者的适应性评估

目标函数候选者群体中每个个体的适应性分数由其新颖性分数决定,该分数的计算方法我们之前已经讨论过。适应性分数评估的实现被分为两个函数:evaluate_obj_functionsevaluate_individ_obj_function

接下来,我们将讨论这两个函数的实现。

evaluate_obj_functions 函数实现

此函数接受 ObjectiveFun 对象,该对象包含目标函数候选者的种群,并使用它通过以下步骤来估计种群中每个个体的适应度分数:

  1. 首先,我们遍历种群中的所有基因组,并为每个基因组收集新颖性点:
    obj_func_genomes = NEAT.GetGenomeList(obj_function.population)
    for genome in obj_func_genomes:
        n_item = evaluate_individ_obj_function(genome=genome, 
                                            generation=generation)
        n_items_list.append(n_item)
        obj_func_coeffs.append(n_item.data)

在代码中,从 evaluate_individ_obj_function 函数获得的新颖性点被追加到种群新颖性点列表中。此外,我们将新颖性点数据追加到系数对列表中。该系数对列表将用于估计个体迷宫求解器的适应度分数。

  1. 接下来,我们遍历种群基因组的列表,并使用上一步收集到的新颖性点来评估每个基因组的 novelty 分数:
    max_fitness = 0
    for i, genome in enumerate(obj_func_genomes):
        fitness = obj_function.archive.evaluate_novelty_score(
               item=n_items_list[i],n_items_list=n_items_list)
        genome.SetFitness(fitness)
        max_fitness = max(max_fitness, fitness)

使用新颖性点估计的新颖性分数已经收集在新颖性存档中和为当前种群创建的新颖性点列表中。之后,我们将估计的新颖性分数设置为相应基因组的适应度分数。此外,我们找到适应度分数的最大值,并返回它,以及系数对列表。

evaluate_individ_obj_function 函数实现

此函数接受目标函数候选者的个体 NEAT 基因组,并返回新颖性点评估结果。我们按以下方式实现它:

    n_item = archive.NoveltyItem(generation=generation, genomeId=genome_id)
    # run the simulation
    multi_net = NEAT.NeuralNetwork()
    genome.BuildPhenotype(multi_net)
    depth = 2
    try:
        genome.CalculateDepth()
        depth = genome.GetDepth()
    except:
        pass
    obj_net = ANN(multi_net, depth=depth)

    # set inputs and get outputs ([a, b])
    output = obj_net.activate([0.5])

    # store coefficients
    n_item.data.append(output[0])
    n_item.data.append(output[1])

我们从创建一个 NoveltyItem 对象开始,以保存给定基因组的 novelty 点数据。之后,我们构建一个表型人工神经网络(ANN)并用输入 0.5 激活它。最后,我们使用 ANN 的输出创建 novelty 点。

在下一节中,我们将讨论迷宫求解种群中个体的适应度分数评估。

迷宫求解代理的适应度评估

我们将迷宫求解种群中每个个体的适应度分数估计为一个由两个组成部分组成的复合体:新颖性分数和轨迹结束时到达迷宫出口的距离。每个组成部分的影响由目标函数候选者种群中个体产生的系数对控制。

适应度分数评估分为三个函数,我们将在下面讨论。

evaluate_solutions 函数实现

evaluate_solutions 函数接收 Robot 对象作为输入参数,该对象维护迷宫求解代理的种群和迷宫环境模拟器。它还接收在评估目标函数候选者种群期间生成的系数对列表。

我们使用函数的输入参数来评估种群中的每个基因组,并估计其适应度函数。在这里,我们讨论基本实现细节:

  1. 首先,我们将种群中的每个个体与迷宫模拟器进行评估,并找到轨迹末尾到迷宫出口的距离:
    robot_genomes = NEAT.GetGenomeList(robot.population)
    for genome in robot_genomes:
        found, distance, n_item = evaluate_individual_solution(
            genome=genome, generation=generation, robot=robot)
        # store returned values
        distances.append(distance)
        n_items_list.append(n_item)
  1. 接下来,我们遍历种群中的所有基因,并估计每个个体的新颖度得分。同时,我们使用之前收集的相应的到迷宫出口的距离,并将其与计算出的新颖度得分结合起来,以评估基因的适应度:
    for i, n_item in enumerate(n_items_list):
        novelty = robot.archive.evaluate_novelty_score(item=n_item, 
                                         n_items_list=n_items_list)
        # The sanity check
        assert robot_genomes[i].GetID() == n_item.genomeId

        # calculate fitness
        fitness, coeffs = evaluate_solution_fitness(distances[i], 
                                        novelty, obj_func_coeffs)
        robot_genomes[i].SetFitness(fitness)

在代码的前半部分,我们使用robot.archive.evaluate_novelty_score函数来估计种群中每个个体的新颖度得分。后半部分调用evaluate_solution_fitness函数,使用新颖度得分和到迷宫出口的距离来估计每个个体的适应度得分。

  1. 最后,我们收集关于种群中最佳迷宫求解器基因的性能评估统计数据:
        if not solution_found:
            # find the best genome in population
            if max_fitness < fitness:
                max_fitness = fitness
                best_robot_genome = robot_genomes[i]
                best_coeffs = coeffs
                best_distance = distances[i]
                best_novelty = novelty
        elif best_robot_genome.GetID() == n_item.genomeId:
            # store fitness of winner solution
            max_fitness = fitness
            best_coeffs = coeffs
            best_distance = distances[i]
            best_novelty = novelty

最后,函数返回在种群评估过程中收集的所有统计数据。

此后,我们讨论如何评估个体迷宫求解器基因相对于迷宫环境模拟器。

evaluate_individual_solution函数的实现

这是评估特定迷宫求解器相对于迷宫环境模拟器性能的函数。其实现如下:

  1. 首先,我们创建迷宫求解器的表型人工神经网络(ANN),并将其用作控制器来引导机器人穿越迷宫:
    n_item = archive.NoveltyItem(generation=generation, 
                                 genomeId=genome_id)
    # run the simulation
    maze_env = copy.deepcopy(robot.orig_maze_environment)
    multi_net = NEAT.NeuralNetwork()
    genome.BuildPhenotype(multi_net)
    depth = 8
    try:
        genome.CalculateDepth()
        depth = genome.GetDepth()
    except:
        pass
    control_net = ANN(multi_net, depth=depth)
    distance = maze.maze_simulation_evaluate(
        env=maze_env, net=control_net, 
        time_steps=SOLVER_TIME_STEPS, n_item=n_item)

在代码中,我们创建一个NoveltyItem对象来保存创新点,该创新点由机器人在迷宫中的最终位置定义。之后,我们创建表型ANN并运行迷宫模拟器,将其用作控制ANN进行给定数量的时间步(400)。模拟完成后,我们接收迷宫求解器最终位置与迷宫出口之间的距离。

  1. 接下来,我们将模拟统计信息保存到我们在实验结束时分析的AgentRecord对象中:
    record = agent.AgenRecord(generation=generation, 
                              agent_id=genome_id)
    record.distance = distance
    record.x = maze_env.agent.location.x
    record.y = maze_env.agent.location.y
    record.hit_exit = maze_env.exit_found
    record.species_id = robot.get_species_id(genome)
    robot.record_store.add_record(record)

之后,该函数返回一个包含以下值的元组:一个标志,指示我们是否找到了解决方案,机器人轨迹末尾到迷宫出口的距离,以及封装有关发现的创新点信息的NoveltyItem对象。

在下一节中,我们讨论迷宫求解器适应度函数的实现。

evaluate_solution_fitness函数的实现

此函数是实现我们之前讨论过的迷宫求解器适应度函数。该函数接收到迷宫出口的距离、新颖度得分以及当前目标函数候选者生成器生成的系数对列表。然后,它使用接收到的输入参数来计算适应度得分,如下所示:

    normalized_novelty = novelty
    if novelty >= 1.00:
        normalized_novelty = math.log(novelty)
    norm_distance = math.log(distance)

    max_fitness = 0
    best_coeffs = [-1, -1]
    for coeff in obj_func_coeffs:
        fitness = coeff[0] / norm_distance + coeff[1] * normalized_novelty
        if fitness > max_fitness:
            max_fitness = fitness
            best_coeffs[0] = coeff[0]
            best_coeffs[1] = coeff[1]

首先,我们需要使用自然对数对距离和新颖度得分值进行归一化。这种归一化将保证距离和新颖度得分值始终处于相同的尺度。确保这些值处于相同的尺度是必要的,因为系数对始终在范围 [0,1] 内。因此,如果距离和新颖度得分值具有不同的尺度,一对系数将无法在计算适应度分数时影响每个值的显著性。

代码遍历系数对的列表,并对每一对系数,通过结合距离和新颖度得分值来计算适应度分数。

迷宫求解器的最终适应度分数是所有找到的适应度分数中的最大值。然后,该值和相应的系数对由函数返回。

修改后的迷宫实验运行器

现在,当我们已经实现了创建共同进化种群和评估这些种群中个体适应度的所有必要程序后,我们就可以开始实现实验运行器循环了。

完整的细节可以在maze_experiment_safe.py文件中的run_experiment函数中找到,该文件位于https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter9/maze_experiment_safe.py

在这里,我们讨论实现的关键细节:

  1. 我们从创建共同进化的物种对应种群开始:
    robot = create_robot(maze_env, seed=seed)
    obj_func = create_objective_fun(seed)
  1. 接下来,我们开始进化循环,并如下评估两个种群:
    for generation in range(n_generations):
        # evaluate objective function population
        obj_func_coeffs, max_obj_func_fitness = \
                    evaluate_obj_functions(obj_func, generation)
        # evaluate robots population
        robot_genome, solution_found, robot_fitness, distances, \
        obj_coeffs, best_distance, best_novelty = \
          evaluate_solutions(robot=robot, 
          obj_func_coeffs=obj_func_coeffs, generation=generation)
  1. 在评估种群之后,我们将当前进化代的结果保存为统计数据:
        stats.post_evaluate(max_fitness=robot_fitness, 
                            errors=distances)
        # store the best genome
        best_fitness = robot.population.GetBestFitnessEver()
        if solution_found or best_fitness < robot_fitness:
            best_robot_genome_ser = pickle.dumps(robot_genome)
            best_robot_id = robot_genome.GetID()
            best_obj_func_coeffs = obj_coeffs
            best_solution_novelty = best_novelty
  1. 在进化循环结束时,如果当前代未找到解决方案,我们向两个种群发出信号,使其进入下一个时代:
        if solution_found:
            print('Solution found at generation: %d, best fitness: %f, species count: %d' % (generation, robot_fitness, len(pop.Species)))
            break
        # advance to the next generation
        robot.population.Epoch()
        obj_func.population.Epoch()
  1. 在进化循环完成对指定代数的迭代后,我们可视化收集到的迷宫记录:
        if args is None:
            visualize.draw_maze_records(maze_env, 
                       robot.record_store.records, 
                       view=show_results)
        else:
            visualize.draw_maze_records(maze_env, 
                      robot.record_store.records, 
                      view=show_results, width=args.width, 
                      height=args.height,
                      filename=os.path.join(trial_out_dir, 
                                     'maze_records.svg'))

这里提到的迷宫记录包含在进化过程中收集的迷宫模拟器中每个迷宫求解器基因组的评估统计数据,作为AgentRecord对象。在可视化中,我们使用迷宫绘制每个评估的迷宫求解器的最终位置。

  1. 接下来,我们使用在进化过程中找到的最佳求解器基因组创建的控制ANN进行迷宫求解模拟。迷宫求解器在模拟过程中的轨迹可以如下可视化:
        multi_net = NEAT.NeuralNetwork()
        best_robot_genome.BuildPhenotype(multi_net)

        control_net = ANN(multi_net, depth=depth)
        path_points = []
        distance = maze.maze_simulation_evaluate(
                                    env=maze_env, 
                                    net=control_net, 
                                    time_steps=SOLVER_TIME_STEPS,
                                    path_points=path_points)
        print("Best solution distance to maze exit: %.2f, novelty: %.2f" % (distance, best_solution_novelty))
        visualize.draw_agent_path(robot.orig_maze_environment, 
                          path_points, best_robot_genome,
                          view=show_results, width=args.width, 
                          height=args.height, 
                          filename=os.path.join(trial_out_dir,
                                      'best_solver_path.svg'))

首先,代码从最佳求解器基因组创建一个表型人工神经网络(ANN)。然后,它使用创建的表型ANN作为迷宫求解器控制器运行迷宫模拟器。我们随后绘制迷宫求解器的收集轨迹点。

  1. 最后,我们以下列方式绘制每代的*均适应度分数图:
        visualize.plot_stats(stats, ylog=False, view=show_results, 
           filename=os.path.join(trial_out_dir,'avg_fitness.svg'))

这里提到的所有可视化内容也都以SVG文件的形式保存在本地文件系统中,以后可用于结果分析。

在下一节中,我们将讨论如何运行修改后的迷宫实验以及实验结果。

修改后的迷宫实验

我们几乎准备好使用修改后的迷宫实验开始协同进化实验。然而,在那之前,我们需要讨论每个协同进化种群的超参数选择。

迷宫求解器种群的超参数

对于这个实验,我们选择使用MultiNEAT Python库,该库使用Parameters Python类来维护所有支持的超参数列表。迷宫求解器种群的超参数初始化在create_robot_params函数中定义。接下来,我们将讨论关键超参数及其选择特定值的原因:

  1. 我们决定从一开始就有一个中等大小的种群,以提供足够的种群多样性:
    params.PopulationSize = 250
  1. 我们对在进化过程中产生紧凑的基因组拓扑结构以及限制种群中物种数量感兴趣。因此,我们在进化过程中定义了非常小的添加新节点和连接的概率:
    params.MutateAddNeuronProb = 0.03
    params.MutateAddLinkProb = 0.05
  1. 新颖度得分奖励在迷宫中找到独特位置。实现这一目标的一种方法是在表型中增强数值动力学。因此,我们增加了连接权重的范围:
    params.MaxWeight = 30.0
    params.MinWeight = -30.0
  1. 为了支持进化过程,我们选择通过定义传递到下一代基因组的比例来引入精英主义:
    params.Elitism = 0.1

精英主义值决定了大约十分之一的个体将被带到下一代。

目标函数候选种群的超参数

我们在create_objective_fun_params函数中为客观函数候选种群的进化创建超参数。在这里,我们讨论最重要的超参数:

  1. 我们决定从一个小的种群开始,以减少计算成本。此外,预期目标函数候选的基因型不会非常复杂。因此,一个小种群应该足够:
    params.PopulationSize = 100
  1. 与迷宫求解器类似,我们感兴趣的是产生紧凑的基因组。因此,添加新节点和连接的概率保持非常小:
    params.MutateAddNeuronProb = 0.03
    params.MutateAddLinkProb = 0.05

我们不期望目标函数候选种群中的基因组拓扑结构复杂。因此,大多数超参数都设置为默认值。

工作环境设置

在这个实验中,我们使用MultiNEAT Python库。因此,我们需要创建一个合适的Python环境,其中包括这个库和其他依赖项。您可以使用以下命令在Anaconda的帮助下设置Python环境:

$ conda create --name maze_co python=3.5
$ conda activate maze_co
$ conda install -c conda-forge multineat 
$ conda install matplotlib
$ conda install graphviz
$ conda install python-graphviz

这些命令创建了一个使用Python 3.5的maze_co虚拟环境,并将所有必要的依赖项安装到其中。

运行修改后的迷宫实验

现在,我们已经准备好在新创建的虚拟环境中运行实验。你可以通过克隆相应的Git仓库并使用以下命令运行脚本来开始实验:

$ git clone https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python.git
$ cd Hands-on-Neuroevolution-with-Python/Chapter9
$ python maze_experiment_safe.py -t 1 -g 150 -m medium

不要忘记使用conda activate maze_co命令激活适当的虚拟环境。

前面的命令启动了一个实验的试验,使用中等复杂性的迷宫配置进行150代的进化。大约在100代进化后,神经进化过程发现了一个成功的解决方案,你应该能够在控制台看到以下输出:

****** Generation: 105 ******

Maze solved in 338 steps

Solution found at generation: 105, best fitness: 3.549289, species count: 7

==================================
Record store file: out/maze_medium_safe/5/data.pickle
Random seed: 1571021768
Best solution fitness: 3.901621, genome ID: 26458
Best objective func coefficients: [0.7935419704765059, 0.9882050653334634]
------------------------------
Maze solved in 338 steps
Best solution distance to maze exit: 3.56, novelty: 19.29
------------------------
Trial elapsed time: 4275.705 sec
==================================

从这里展示的输出中,你可以看到在第105代找到了一个成功的迷宫求解器,并且能够在400步中解决迷宫。有趣的是,注意到由最佳目标函数候选者产生的系数对给迷宫求解器适应度函数的新颖度分数组件赋予了略微更多的重视。

看一下每代最佳适应度分数的图表也是很有趣的:

图片

每代的适应度分数

在前面的图表中,你可以看到最佳适应度分数在进化的早期代数达到最大值。这是由于高新颖度分数值,在进化的开始阶段更容易获得,因为有许多迷宫区域尚未被探索。另一个需要注意的重要点是,*均距离到迷宫出口在大多数进化代数中几乎保持在同一水*。因此,我们可以假设正确的解决方案不是通过逐步改进,而是通过冠军基因的质量飞跃找到的。这一结论也得到下一个图表的支持,其中我们按物种渲染收集到的迷宫记录:

图片

记录了最终迷宫求解器的位置

前面的图表分为两部分:上面部分是具有目标适应度分数(基于迷宫出口的距离)大于0.8的物种,下面部分是其他物种。你可以看到只有一种物种产生了能够到达迷宫出口附*区域的迷宫求解器基因组。此外,你还可以看到,该物种的基因组通过探索比所有其他物种加起来还要多的迷宫区域,表现出非常探索性的行为。

最后,我们讨论了成功迷宫求解器在迷宫中的路径,如下面的图表所示:

图片

成功迷宫求解器在迷宫中的路径

成功迷宫求解器的路径对于给定的迷宫配置来说是*最优的。

这个实验也展示了初始条件在找到成功解决方案中的重要性。初始条件是由我们在运行实验之前选择的随机种子值定义的。

练习

  1. 我们已经将难以解决的迷宫配置纳入了实验源代码中,可以在https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter9/hard_maze.txt找到。你可以通过以下命令尝试解决这个困难的迷宫配置:python maze_experiment_safe.py -g 120 -t 5 -m hard --width 200 --height 200

  2. 我们发现使用1571021768作为随机种子值是一个成功的解决方案。尝试找到另一个产生成功解决方案的随机种子值。找到它需要多少代?

摘要

在本章中,我们讨论了两种物种群体的协同进化。你学习了如何通过共生协同进化来产生一群成功的迷宫解决者。我们向你介绍了一种令人兴奋的方法,即结合基于目标的分数和新颖性分数,使用目标函数候选群体产生的系数来实现迷宫解决者的适应度函数。此外,你还了解了改进后的新颖性搜索方法,以及它与我们在第6章“新颖性搜索优化方法”中讨论的原方法有何不同。

利用本章获得的知识,你将能够将共生协同进化方法应用于你的工作或研究任务,这些任务没有明确的适应度函数定义。

在下一章中,你将了解深度神经进化方法以及如何使用它来进化能够玩经典Atari游戏的智能体。

第十三章:深度神经进化

在本章中,你将了解深度神经进化方法,该方法可用于训练深度神经网络(DNN)。DNN传统上使用基于误差梯度下降的逆传播方法进行训练,该误差梯度是根据神经节点之间连接的权重计算的。尽管基于梯度的学习是一种强大的技术,它构思了当前深度机器学习的时代,但它也有其缺点,例如训练时间长和计算能力要求巨大。

在本章中,我们将展示如何使用深度神经进化方法进行强化学习,以及它们如何显著优于传统的DQN、A3C基于梯度的DNN训练学习方法。到本章结束时,你将深入理解深度神经进化方法,并且将获得实际操作经验。我们将学习如何通过深度神经进化进化智能体,使其能够玩经典的Atari游戏。此外,你还将学习如何使用神经进化视觉检查器(VINE)来检查实验结果。

本章将涵盖以下主题:

  • 深度神经进化用于深度强化学习

  • 使用深度神经进化进化智能体以玩Frostbite Atari游戏

  • 训练智能体玩Frostbite游戏

  • 运行Frostbite Atari实验

  • 使用VINE检查结果

技术要求

为了完成本章中描述的实验,以下技术要求必须满足:

  • 一台配备Nvidia显卡加速器GeForce GTX 1080Ti或更好的现代PC

  • MS Windows 10、Ubuntu Linux 16.04或macOS 10.14,并配备离散GPU

  • Anaconda Distribution版本2019.03或更高版本

本章的代码可以在https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/tree/master/Chapter10找到

深度神经进化用于深度强化学习

在本书中,我们已经介绍了神经进化方法如何应用于解决简单的强化学习任务,例如在第4章中提到的单杆和双杆*衡实验,杆*衡实验。然而,尽管杆*衡实验令人兴奋且易于进行,但它相当简单,并且使用的是微型人工神经网络。在本章中,我们将讨论如何将神经进化应用于需要巨大人工神经网络来*似强化学习算法价值函数的强化学习问题。

RL算法通过试错来学习。几乎所有的RL算法变体都试图优化值函数,该函数将系统的当前状态映射到下一个时间步将执行的正确动作。最广泛使用的经典RL算法版本是Q学习,它围绕一个由动作键入的状态表构建,该表构成了算法在训练完成后要遵循的策略规则。训练包括在特定状态下迭代执行特定动作,并在之后收集奖励信号,以更新Q表的单元格。以下公式决定了更新Q表中特定单元格的过程:

图片

这里 奖励 是当系统状态从状态 初始状态 变化到状态 目标状态 时所获得的奖励,动作 是在时间 时间点 所采取的动作,导致状态变化,学习率 是学习率,而折扣因子 是一个控制未来奖励重要性的折扣因子。学习率决定了新信息在特定Q表单元格中覆盖现有信息到何种程度。如果我们把学习率设为零,那么将不会学习任何东西,如果我们把它设为 1,那么将不会保留任何东西。因此,学习率控制了系统学习新信息的同时保持有用、已学数据的速度。

Q学习算法的简单版本迭代所有可能的动作-状态组合,并更新Q值,正如我们之前讨论的那样。这种方法对于具有少量动作-状态对的简单任务来说效果很好,但随着这种对的数量增加,即动作-状态空间的维度增加,这种方法很快就会失败。大多数现实世界任务都具有深刻的动作-状态空间维度,这使得经典版本的Q学习变得不可行。

提出Q值函数逼*方法是为了解决维度增加的问题。在这个方法中,Q学习策略不是由我们之前提到的动作-状态表定义的,而是通过一个函数来逼*。实现这种逼*的一种方法是用ANN作为通用逼*函数。通过使用ANN,特别是用于Q值逼*的深度ANN,使得使用RL算法解决非常复杂的问题成为可能,甚至可以解决具有连续状态空间的问题。因此,设计了DQN方法,它使用DNN进行Q值逼*。基于DNN值函数逼*的RL被称为深度强化学习深度RL)。

使用深度强化学习,我们可以直接从视频流的像素中学习动作策略。这使得我们可以使用视频流来训练智能体玩电子游戏,例如。然而,DQN方法可以被认为是一种基于梯度的方法。它使用DNN中的误差(损失)反向传播来优化Q值函数*似器。虽然这是一种强大的技术,但它涉及到显著的计算复杂度,这需要使用GPU来执行梯度下降相关计算中的所有矩阵乘法。

可以用来减少计算成本的其中一种方法是遗传算法GA),例如神经进化。神经进化允许我们在不涉及任何基于梯度的计算的情况下,进化一个用于Q值函数*似的DNN。在最*的研究中,已经表明,无梯度GA方法在挑战性的深度强化学习任务中表现出色,并且甚至可以超越它们的传统对应物。在下一节中,我们将讨论如何使用深度神经进化方法来训练成功的智能体,仅通过读取游戏屏幕观察来玩一款经典的Atari游戏。

通过深度神经进化使智能体学会玩Frostbite Atari游戏

最*,经典的Atari游戏被封装在Atari学习环境ALE)中,成为测试不同RL算法实现的基准。针对ALE测试的算法需要从游戏屏幕的像素中读取游戏状态,并设计复杂的控制逻辑,使智能体能够赢得游戏。因此,算法的任务是在游戏角色及其对手的背景下,演变对游戏情况的理解。此外,算法还需要理解从游戏屏幕接收到的奖励信号,这种信号以单次游戏运行结束时的最终游戏分数的形式出现。

Frostbite Atari游戏

Frostbite是一款经典的Atari游戏,玩家控制一个游戏角色,该角色正在建造一个冰屋。游戏屏幕在下面的屏幕截图中显示:

Frostbite游戏屏幕

Frostbite游戏屏幕

屏幕底部是水,有浮冰块排列成四行。游戏角色在尝试避开各种敌人时从一个行跳到另一个行。如果游戏角色跳到一个白色冰块上,这个块就会被收集并用于在屏幕右上角的岸边建造一个冰屋。之后,白色冰块会改变颜色,不能再使用。

要建造冰屋,游戏角色必须在45秒内收集15个冰块。否则,游戏结束,因为游戏角色被冻住了。当冰屋完成时,游戏角色必须进入其中以完成当前关卡。游戏角色完成关卡越快,玩家获得的额外分数就越多。

接下来,我们将讨论如何将游戏屏幕状态映射到输入参数,这些参数可以被神经进化方法使用。

游戏屏幕映射到动作

如果深度ANN能够直接将屏幕上的像素映射到控制游戏系统的系统,它们就可以被训练来玩Atari游戏。这意味着我们的算法必须读取游戏屏幕并决定采取什么游戏动作以获得尽可能高的游戏分数。

此任务可以分为两个逻辑子任务:

  • 图像分析任务,该任务将当前游戏情况在屏幕上的状态进行编码,包括游戏角色的位置、障碍物和对手

  • RL训练任务,用于训练Q值*似ANN以建立特定游戏状态与要执行的动作之间的正确映射

卷积神经网络CNNs)在分析视觉图像或其他高维欧几里得数据相关的任务中常用。CNN的强大之处在于,如果应用于视觉识别,它们能够显著减少与其他类型的ANN相比的学习参数数量。CNN层次结构通常由多个顺序卷积层与非线性的全连接层结合,并以一个全连接层结束,该层之后是损失层。最终的全连接和损失层实现了神经网络架构中的高级推理。在深度RL的情况下,这些层实现了Q值*似。接下来,我们将考虑卷积层实现的细节。

卷积层

通过研究高等生命形式(包括人类)的视觉皮层组织,研究人员为CNN的设计获得了灵感。视觉皮层的每个神经元对来自视觉场有限区域的信号做出反应——神经元的接收场。不同神经元的接收场部分重叠,这使得它们能够覆盖整个视觉场,如下面的图所示:

接收场(左侧)与卷积层(右侧)中的神经元之间的连接方案

卷积层由一列神经元组成,其中每一列中的神经元都连接到相同的接收场。这一列代表了一组过滤器(核)。每个过滤器由接收场的大小和通道数定义。通道数定义了神经元列的深度,而接收场的大小决定了卷积层中的列数。当接收场在视觉场中移动时,在每一步,新的神经元列被激活。

如我们之前提到的,每个卷积层通常与非线性的激活函数结合,例如Rectified Linear UnitReLU)。ReLU激活函数的作用是过滤掉负值,如下公式所示:

这里,是神经元的输入。

在ANN架构中,几个卷积层连接到多个完全连接层,执行高级推理。接下来,我们将讨论在我们的实验中使用的CNN架构。

训练Atari游戏代理的CNN架构

在我们的实验中,我们将使用由三个卷积层组成的CNN架构,这些卷积层具有32、64和64个通道,随后是一个具有512个单元的完全连接层和与游戏动作数量相对应的输出层。卷积层的核大小分别为8 x 8、4 x 4和3 x 3,分别使用步长为4、2和1。ReLU非线性函数跟随所有的卷积层和完全连接层。

使用TensorFlow框架创建描述的网络图模型的源代码定义如下:

class LargeModel(Model):
    def _make_net(self, x, num_actions):
        x = self.nonlin(self.conv(x, name='conv1', num_outputs=32, 
                                  kernel_size=8, stride=4, std=1.0))
        x = self.nonlin(self.conv(x, name='conv2', num_outputs=64, 
                                  kernel_size=4, stride=2, std=1.0))
        x = self.nonlin(self.conv(x, name='conv3', num_outputs=64, 
                                  kernel_size=3, stride=1, std=1.0))
        x = self.flattenallbut0(x)
        x = self.nonlin(self.dense(x, 512, 'fc'))

        return self.dense(x, num_actions, 'out', std=0.1)

由于这种架构,CNN包含大约400万个可训练参数。接下来,我们将讨论在我们的实验中如何进行RL训练。

对于完整的实现细节,请参阅https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter10/neuroevolution/models/dqn.py中的dqn.py Python脚本。

游戏代理的RL训练

我们的实验中的RL训练是使用神经进化方法实现的。这种方法基于一个简单的遗传算法,该算法进化一个个体群体。每个个体的基因型编码了控制器ANN的可训练参数向量。我们所说的可训练参数是指网络节点之间的连接权重。在每一代中,每个基因型通过在Frostbite中玩游戏来与测试环境进行评估,并产生一个特定的适应度分数。我们评估每个代理(基因组)对游戏的20,000帧。在评估期间,游戏角色可以玩多次,最终Atari游戏得分是适应度分数,这是RL中的奖励信号。

接下来,我们将讨论基因组编码方案,它允许我们编码控制游戏解决代理的ANN的超过400万学习参数。

基因组编码方案

我们用作游戏代理控制器的深度强化学习神经网络大约有400万个可训练参数。每个可训练参数是神经网络中两个节点之间连接的权重。传统上,训练神经网络是关于找到所有连接权重的适当值,使神经网络能够*似描述建模过程具体情况的函数。

传统的估计这些可训练参数的方法是使用基于损失值梯度下降的某种形式的误差反向传播,这非常计算密集。另一方面,神经进化算法允许我们使用一种受自然界启发的遗传算法来训练ANN。神经进化算法通过对可训练参数应用一系列突变和重组来找到ANN的正确配置。然而,要使用遗传算法,应设计一种适当的表型ANN编码方案。之后,可以使用简单的遗传算法创建和进化个体(编码表型ANN的基因组),我们将在后面讨论。

如我们之前提到的,编码方案应生成紧凑的基因组,能够编码控制游戏代理的深度强化学习人工神经网络(ANN)节点之间超过400万个连接权重的值。我们正在寻找紧凑的基因组以降低与遗传算法评估相关的计算成本。接下来,我们将讨论基因组编码方案的定义,该方案可用于编码大型表型ANN。

基因组编码方案定义

优步AI实验室的研究人员提出了一种编码方案,该方案使用伪随机数生成器的种子来编码表型ANN。在这个方案中,基因组表示为种子值的列表,这些种子值依次应用以生成控制器ANN节点之间表达的所有连接(可训练参数)的值。

换句话说,列表中的第一个种子值代表策略初始化种子,它被单个父代的谱系所共享。所有后续的种子值代表后代在进化过程中获得的具体突变。每个种子依次应用以产生特定表型的ANN参数向量。以下公式定义了特定个体的表型参数向量估计(图片):

图片

这里,的编码,由一系列突变种子组成;是一个具有输入种子的确定性高斯伪随机数生成器,它产生一个长度为的向量;是在初始化期间创建的初始参数向量,如下所示,,其中是一个确定性初始化函数;而是突变能力,它决定了所有后续参数向量对初始参数向量的影响强度。

在当前实现中,是一个使用28位种子索引的预计算表,包含2.5亿个随机向量。这样做是为了加快运行时处理速度,因为通过索引查找比生成新的随机数要快。接下来,我们将讨论如何在Python源代码中实现编码方案。

基因编码方案实现

下面的源代码实现了按前一小节中定义的公式(参见compute_weights_from_seeds函数)进行的ANN参数估计:

    idx = seeds[0]
    theta = noise.get(idx, self.num_params).copy() * self.scale_by

    for mutation in seeds[1:]:
        idx, power = mutation
        theta = self.compute_mutation(noise, theta, idx, power)
    return theta

compute_mutation函数实现了ANN参数估计的单步估计,如下所示:

    def compute_mutation(self, noise, parent_theta, idx, mutation_power):
        return parent_theta + mutation_power * noise.get(idx, 
                                                        self.num_params)

上述代码将父代可训练参数的向量与由确定性伪随机生成器使用特定种子索引产生的随机向量相加。突变能力参数在将其添加到父代参数向量之前对生成的随机向量进行缩放。

更多实现细节,请参阅https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter10/neuroevolution/models/base.py中的base.py脚本。

接下来,我们将讨论用于训练Frostbite游戏代理的简单遗传算法的细节。

简单遗传算法

在我们的实验中使用的简单遗传算法通过进化代数来进化N个个体的种群。正如我们之前提到的,每个个体的基因组编码了可训练的ANN参数向量。此外,在每一代中,我们选择前T个个体成为下一代的双亲。

产生下一代的流程如下。对于N-1次重复,我们执行以下操作:

  1. 随机选择一个父代并从选择列表中移除。

  2. 通过对个体编码的参数向量应用加性高斯噪声,将突变应用于选定的父个体。

  3. 接下来,我们将新的生物体添加到下一代的个体列表中。

之后,将当前代最佳个体以未修改的状态复制到下一代(精英主义)。为了保证最佳个体被选中,我们将当前代的10个顶级个体与30个额外的游戏关卡进行评估。然后,*均适应度分数最高的个体被选为精英,复制到下一代。

父个体突变的具体实现如下:

    def mutate(self, parent, rs, noise, mutation_power):
        parent_theta, parent_seeds = parent
        idx = noise.sample_index(rs, self.num_params)
        seeds = parent_seeds + ((idx, mutation_power), )
        theta = self.compute_mutation(noise, parent_theta, idx, 
                                      mutation_power)
        return theta, seeds

此函数接收父个体的表型和基因型、随机源、预计算的噪声表(2.5亿个向量)以及突变功率值。随机源生成随机种子数(idx),用作索引,以便我们可以从噪声表中选择适当的参数向量。之后,我们通过将父种子列表与新的种子结合来创建后代基因组。最后,我们通过将父个体的表型与从共享噪声表中使用先前获得的随机采样种子索引(idx)提取的高斯噪声相结合来创建后代的表型。在下一节中,我们将探讨我们可以进行的实验,以训练一个能够玩Frostbite Atari游戏的智能体。

训练智能体玩Frostbite游戏

既然我们已经讨论了游戏智能体实现背后的理论,我们现在可以开始着手工作了。我们的实现基于GitHub上Uber AI实验室提供的源代码,网址为https://github.com/uber-research/deep-neuroevolution。该存储库中的源代码包含两种训练DNN的方法:适用于多核系统的基于CPU的方法(最多720个核心)和基于GPU的方法。我们感兴趣的是基于GPU的实现,因为大多数实践者无法访问拥有720个CPU核心的PC这样的巨型技术设备。同时,获取现代Nvidia GPU相当容易。

接下来,我们将讨论实现的细节。

Atari学习环境

在智能体训练期间,我们需要在Atari系统中模拟实际游戏玩法。这可以通过使用ALE来完成,它模拟了一个可以运行游戏ROM图像的Atari系统。ALE提供了一个接口,允许我们通过模拟游戏控制器来捕获游戏屏幕帧和控制游戏。在这里,我们将使用可在https://github.com/yaricom/atari-py找到的ALE修改版。

我们的实现使用 TensorFlow 框架来实现 ANN 模型并在 GPU 上执行它们。因此,需要在 ALE 和 TensorFlow 之间实现相应的桥梁。这是通过使用 C++ 编程语言实现自定义 TensorFlow 操作来实现的,以提高效率。还提供了相应的 Python 接口,作为 AtariEnv Python 类,在 https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter10/gym_tensorflow/atari/tf_atari.py

AtariEnv 提供了函数,使我们能够执行单个游戏步骤、重置游戏并返回当前游戏状态(观察)。接下来,我们将讨论每个函数。

游戏步骤函数

游戏步骤函数使用提供的动作执行单个游戏步骤。该函数的实现如下:

    def step(self, action, indices=None, name=None):
        if indices is None:
            indices = np.arange(self.batch_size)
        with tf.variable_scope(name, default_name='AtariStep'):
            rew, done = gym_tensorflow_module.environment_step(
                               self.instances, indices, action)
            return rew, done

此函数将控制器 ANN 接收到的游戏动作应用于当前游戏环境。请注意,此函数可以在多个游戏实例中同时执行单个游戏步骤。self.batch_size 参数或 indices 输入张量的长度决定了我们将拥有的游戏实例数量。该函数返回两个张量:一个包含奖励(游戏得分)的张量,另一个包含标志,指示当前游戏评估在此步骤后是否完成(解决或失败)。这两个张量的长度等于 self.batch_sizeindices 输入张量的长度。

接下来,我们将讨论游戏观察是如何创建的。

游戏观察函数

此函数从 Atari 环境获取当前游戏状态作为游戏屏幕缓冲区。此函数的实现如下:

    def observation(self, indices=None, name=None):
        if indices is None:
            indices = np.arange(self.batch_size)
        with tf.variable_scope(name, default_name='AtariObservation'):
            with tf.device('/cpu:0'):
                obs = gym_tensorflow_module.environment_observation(
                                   self.instances, indices, T=tf.uint8)

            obs = tf.gather(tf.constant(self.color_pallete), 
                                                tf.cast(obs,tf.int32))
            obs = tf.reduce_max(obs, axis=1)
            obs = tf.image.resize_bilinear(obs, self.warp_size, 
                                                   align_corners=True)
            obs.set_shape((None,) + self.warp_size + (1,))
            return obs

此函数从 Atari 环境获取屏幕截图,并将其包装在 TensorFlow 框架可以使用的张量中。游戏观察函数还允许我们通过 self.batch_size 参数或 indices 输入参数的长度接收来自多个游戏的州。该函数返回多个游戏的屏幕截图,包装在张量中。

我们还需要实现一个函数,将 Atari 环境重置到初始随机状态,我们将在下一节中讨论。

重置 Atari 环境函数

为了训练游戏智能体,我们需要实现一个函数,从特定的随机状态启动 Atari 环境。实现一个随机的 Atari 重置函数对于确保我们的智能体可以从任何初始状态玩游戏至关重要。该函数的实现如下:

    def reset(self, indices=None, max_frames=None, name=None):
        if indices is None:
            indices = np.arange(self.batch_size)
        with tf.variable_scope(name, default_name='AtariReset'):
            noops = tf.random_uniform(tf.shape(indices), minval=1, 
                                       maxval=31, dtype=tf.int32)
            if max_frames is None:
                max_frames = tf.ones_like(indices, dtype=tf.int32) * \
                                         (100000 * self.frameskip)
            import collections
            if not isinstance(max_frames, collections.Sequence):
                max_frames = tf.ones_like(indices, dtype=tf.int32) * \
                                          max_frames
            return gym_tensorflow_module.environment_reset(self.instances, 
                             indices, noops=noops, max_frames=max_frames)

此函数使用 indices 输入参数同时重置多个 Atari 游戏实例的随机初始状态。此函数还定义了每个游戏实例的最大帧数。

接下来,我们将讨论如何在 GPU 核心上执行 RL 评估。

在 GPU 核心上进行 RL 评估

在我们的实验中,我们将使用 TensorFlow 框架在 GPU 设备上实现一个强化学习评估。这意味着与控制器 ANN 中输入信号传播相关的所有计算都在 GPU 上执行。这使得我们能够有效地计算超过 400 万个训练参数——控制 ANN 节点之间的连接权重——对于游戏中的每一个时间步。此外,我们还可以并行模拟多个游戏运行,每个运行由不同的控制器 ANN 控制。

通过两个 Python 类 RLEvalutionWorkerConcurrentWorkers 实现多个游戏控制器 ANN 的并发评估。接下来,我们将讨论每个类。

对于完整的实现细节,请参阅 https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter10/neuroevolution/concurrent_worker.py 中的 concurrent_worker.py 类。

RLEvalutionWorker 类

此类包含控制器 ANN 的配置和网络图。它为我们提供了方法,以便我们可以创建控制器 ANN 的网络图,在创建的网络图中运行评估循环,并将新任务放入评估循环中。接下来,我们将讨论网络图是如何从网络模型创建的。

创建网络图

TensorFlow 网络图是由 make_net 函数创建的,该函数接收 ANN 模型构造函数、GPU 设备标识符和批处理大小作为输入参数。网络图的创建如下:

  1. 我们将首先创建控制器 ANN 模型和游戏评估环境:
    self.model = model_constructor()
    …
    with tf.variable_scope(None, default_name='model'):
        with tf.device(‘/cpu:0'):
            self.env = self.make_env_f(self.batch_size)
  1. 接下来,我们将创建占位符,以便在网络图评估期间接收值。同时,我们还将创建一个操作员,在新游戏剧集开始前重置游戏:
        self.placeholder_indices = tf.placeholder(tf.int32, 
                                                    shape=(None, ))
        self.placeholder_max_frames = tf.placeholder(
                                          tf.int32, shape=(None, ))
        self.reset_op = self.env.reset(
                            indices=self.placeholder_indices, 
                            max_frames=self.placeholder_max_frames)
  1. 之后,使用提供的 GPU 设备的上下文,我们将创建两个操作员来接收游戏状态观察并评估后续的游戏动作:
        with tf.device(device):
            self.obs_op = self.env.observation(
                            indices=self.placeholder_indices)
            obs = tf.expand_dims(self.obs_op, axis=1)
            self.action_op = self.model.make_net(obs, 
                            self.env.action_space, 
                            indices=self.placeholder_indices, 
                            batch_size=self.batch_size, 
                            ref_batch=ref_batch)
  1. action 操作符返回一个动作可能性值的数组,如果动作空间是离散的,则需要过滤:
        if self.env.discrete_action:
            self.action_op = tf.argmax(
                        self.action_op[:tf.shape(
                        self.placeholder_indices)[0]], 
                        axis=-1, output_type=tf.int32)

代码检查当前游戏环境是否需要离散动作,并使用 TensorFlow 框架内置的 tf.argmax 操作符包装 action 操作符。tf.argmax 操作符返回具有最大值的动作的索引,可以用来指示应该执行特定的游戏动作。

Atari 游戏环境是一个离散动作环境,这意味着在每一个时间步只接受一个动作。

  1. 最后,我们创建一个操作员来执行单个游戏步骤:
        with tf.device(device):
            self.rew_op, self.done_op = \
                       self.env.step(self.action_op, 
                       indices=self.placeholder_indices)

在这里,我们创建一个单个游戏步骤操作符,该操作符在执行单个游戏步骤后返回获取奖励的操作 self.rew_op 和游戏完成状态 self.done_op

接下来,我们将讨论评估循环是如何实现的。

图形评估循环

这是用于并行评估先前创建的网络图在多个游戏中的循环——可以同时评估的游戏数量由 batch_size 参数确定。

评估循环定义在 _loop 函数中,并如下实现:

  1. 首先,我们从创建数组开始,这些数组用于存储多个连续游戏中的游戏评估值:
running = np.zeros((self.batch_size,), dtype=np.bool)
cumrews = np.zeros((self.batch_size, ), dtype=np.float32)
cumlen = np.zeros((self.batch_size, ), dtype=np.int32)
  1. 接下来,我们启动循环并将我们刚刚创建的运行数组的相应索引设置为 True
    while True:
        # nothing loaded, block
        if not any(running):
            idx = self.queue.get()
            if idx is None:
               break
            running[idx] = True
        while not self.queue.empty():
           idx = self.queue.get()
           if idx is None:
                 break
           running[idx] = True
  1. 使用索引数组,我们准备执行单个游戏步骤操作并收集结果:
indices = np.nonzero(running)[0]
rews, is_done, _ = self.sess.run(
          [self.rew_op, self.done_op, self.incr_counter], 
          {self.placeholder_indices: indices})
cumrews[running] += rews
cumlen[running] += 1
  1. 最后,我们需要测试是否有任何评估过的游戏已完成,无论是通过获胜还是达到最大游戏帧数限制。对于所有完成的任务,我们应用一系列操作,如下:
if any(is_done):
    for idx in indices[is_done]:
        self.sample_callback[idx](self, idx, 
              (self.model.seeds[idx],cumrews[idx], 
                                         cumlen[idx]))
    cumrews[indices[is_done]] = 0.
    cumlen[indices[is_done]] = 0.
    running[indices[is_done]] = False

前面的代码使用所有已完成任务的索引并调用相应的注册回调,在特定索引重置收集器变量之前。

现在,我们准备讨论如何使用我们的工作器添加和运行新任务。

异步任务运行器

此函数将特定任务注册为在 GPU 设备上下文中由工作器评估的任务。它接受任务 ID、任务对象持有者和任务完成时要执行的回调作为输入。此函数定义为 run_async 并如下实现:

  1. 首先,它从任务对象中提取相应的数据并将其加载到当前 TensorFlow 会话中:
    theta, extras, max_frames=task
    self.model.load(self.sess, task_id, theta, extras)
    if max_frames is None:
        max_frames = self.env.env_default_timestep_cutoff

在这里,theta 是控制器 ANN 模型中所有连接权重的数组,extras 包含相应基因组的随机种子列表,而 max_frames 是游戏帧的截止值。

  1. 接下来,我们使用 self.reset_op 运行 TensorFlow 会话,该操作在指定索引重置特定的游戏环境:
    self.sess.run(self.reset_op, {self.placeholder_indices:[task_id], 
                  self.placeholder_max_frames:[max_frames]})
    self.sample_callback[task_id] = callback
    self.queue.put(task_id)

代码在 TensorFlow 会话中运行 self.reset_op。我们还使用 reset 操作符和给定任务的特定游戏帧的最大截止值注册当前任务标识符。任务标识符在评估循环中使用,以将网络图的评估结果与种群中特定的基因组相关联。接下来,我们将讨论如何维护并发异步工作器。

ConcurrentWorkers

ConcurrentWorkers 类持有并发执行环境的配置,这包括几个评估工作器(RLEvalutionWorker 实例)和辅助例程以支持并发任务的多次执行。

创建评估工作器

ConcurrentWorkers 类的主要职责之一是创建和管理 RLEvalutionWorker 实例。这是在类构造函数中完成的,如下所示:

    self.workers = [RLEvalutionWorker(make_env_f, *args, 
         ref_batch=ref_batch, 
         **dict(kwargs, device=gpus[i])) for i in range(len(gpus))]
    self.model = self.workers[0].model
    self.steps_counter = sum([w.steps_counter for w in self.workers])
    self.async_hub = AsyncTaskHub()
    self.hub = WorkerHub(self.workers, self.async_hub.input_queue, 
                            self.async_hub)

在这里,我们创建与系统中可用的 GPU 设备数量相对应的 RLEvalutionWorker 实例数量。之后,我们初始化选定的 ANN 图模型,并创建辅助例程来管理异步任务的多次执行。接下来,我们将讨论工作任务是如何安排执行的。

运行工作任务和监控结果

要使用我们之前描述的 RL 评估机制,我们需要一种方法来安排工作任务进行评估并监控结果。这通过 monitor_eval 函数实现,它接收种群中的基因组列表并对它们进行 Atari 游戏环境的评估。此函数有两个基本实现部分,我们将在本节中讨论这两个部分:

  1. 首先,我们遍历列表中的所有基因组,创建异步工作任务,以便每个基因组都可以与 Atari 游戏环境进行评估:
    tasks = []
    for t in it:
        tasks.append(self.eval_async(*t, max_frames=max_frames, 
                                    error_callback=error_callback))
        if time.time() - tstart > logging_interval:
            cur_timesteps = self.sess.run(self.steps_counter)
            tlogger.info('Num timesteps:', cur_timesteps, 
             'per second:', 
             (cur_timesteps-last_timesteps)//(time.time()-tstart),
             'num episodes finished: {}/{}'.format(
             sum([1 if t.ready() else 0 for t in tasks]), 
             len(tasks)))
            tstart = time.time()
            last_timesteps = cur_timesteps

前面的代码为列表中的每个基因组安排异步评估,并为每个异步任务保存一个引用以供以后使用。此外,我们定期输出已安排任务的评估过程结果。现在,我们将讨论如何监控评估结果。

  1. 以下代码块正在等待异步任务的完成:
    while not all([t.ready() for t in tasks]):
        if time.time() - tstart > logging_interval:
            cur_timesteps = self.sess.run(self.steps_counter)
            tlogger.info('Num timesteps:', cur_timesteps, 'per second:', (cur_timesteps-last_timesteps)//(time.time()-tstart), 'num episodes:', sum([1 if t.ready() else 0 for t in tasks]))
            tstart = time.time()
            last_timesteps = cur_timesteps
        time.sleep(0.1)

在这里,我们遍历所有对已安排的异步任务的引用,并等待它们的完成。同时,我们定期输出评估进度。接下来,我们将讨论如何收集任务评估结果。

  1. 最后,在所有任务完成后,我们收集结果,如下所示:
    tlogger.info(
       'Done evaluating {} episodes in {:.2f} seconds'.format(
                          len(tasks), time.time()-tstart_all))
    return [t.get() for t in tasks]

代码遍历所有对已安排的异步任务的引用,并创建一个评估结果列表。接下来,我们将讨论实验运行器的实现。

实验运行器

实验运行器实现接收在 JSON 文件中定义的实验配置,并运行指定游戏时间步数的神经进化过程。在我们的实验中,当达到 1.5 亿个 Frostbite 时间步后,评估停止。接下来,我们将讨论实验配置的详细信息。

实验配置文件

这是提供实验运行器配置参数的文件。对于我们的实验,它包含以下内容:

{
    "game": "frostbite",
    "model": "LargeModel",
    "num_validation_episodes": 30,
    "num_test_episodes": 200,
    "population_size": 1000,
    "episode_cutoff_mode": 5000,
    "timesteps": 1.5e9,
    "validation_threshold": 10,
    "mutation_power": 0.002,
    "selection_threshold": 20
}

配置参数如下:

  • game 参数是游戏的名称,如 ALE 中注册的那样。支持的完整游戏列表可在https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter10/gym_tensorflow/atari/tf_atari.py找到。

  • model 参数指定了用于构建控制器ANN的网络图模型的名称。模型在https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter10/neuroevolution/models/dqn.py中定义。

  • num_validation_episodes 参数定义了用于评估群体中顶级个体的游戏剧集数量。在此步骤之后,我们可以选择群体的真正精英。

  • num_test_episodes 参数设置了用于测试所选群体精英性能的游戏剧集数量。

  • population_size 参数决定了群体中的基因组数量。

  • episode_cutoff_mode 参数定义了特定基因组游戏评估停止的方式。游戏剧集可以通过执行特定数量的时间步或使用相应游戏环境的默认停止信号来停止。

  • timesteps 参数设置了在神经进化过程中执行的游戏的总时间步数。

  • validation_threshold 参数设置了从每一代中选择的顶级个体数量,这些个体将进行额外的验证执行。群体精英从这些选定的个体中选出。

  • mutation_power 参数定义了后续添加到个体中的突变如何影响训练参数(连接权重)。

  • selection_threshold 参数决定了下一代中允许产生后代的父个体数量。

现在,我们准备讨论实验运行器的实现细节。

实验配置文件可在https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python/blob/master/Chapter10/configurations/ga_atari_config.json找到。

实验运行器实现

实验运行器实现创建并发评估环境,并在个体群体上运行进化循环。让我们讨论基本实现细节:

  1. 我们首先通过加载控制器ANN模型并创建并发工作者来设置评估环境以执行评估:
    Model = neuroevolution.models.__dict__[config['model']]
    all_tstart = time.time()
    def make_env(b):
        return gym_tensorflow.make(game=config["game"], 
                                   batch_size=b)
    worker = ConcurrentWorkers(make_env, Model, batch_size=64)
  1. 接下来,我们创建一个包含随机噪声值的表格,这些值将用作随机种子,并定义用于创建下一代后代的函数:
    noise = SharedNoiseTable()
    rs = np.random.RandomState()

    def make_offspring():
        if len(cached_parents) == 0:
            return worker.model.randomize(rs, noise)
        else:
            assert len(cached_parents) == config['selection_threshold']
            parent = cached_parents[
                    rs.randint(len(cached_parents))]
            theta, seeds = worker.model.mutate( parent, rs, noise, 
                   mutation_power=state.sample(
                   state.mutation_power))
            return theta, seeds
  1. 然后,主进化循环开始。我们使用之前定义的函数来为当前代创建后代群体:
    tasks = [make_offspring() for _ in range(
                              config['population_size'])]
    for seeds, episode_reward, episode_length in \
        worker.monitor_eval(tasks, max_frames=state.tslimit * 4):
        results.append(Offspring(seeds, 
                       [episode_reward], [episode_length]))

    state.num_frames += sess.run(worker.steps_counter) - \
                                frames_computed_so_far

在这里,我们为群体中的每个后代创建工作任务,并为每个任务安排对游戏环境的评估。

  1. 当我们完成对种群中每个个体的评估后,我们开始评估顶级个体以选择精英:
    state.population = sorted(results, 
                  key=lambda x:x.fitness, reverse=True)
    …
    validation_population = state.\
                   population[:config['validation_threshold']]
    if state.elite is not None:
        validation_population = [state.elite] + \
                                    validation_population[:-1]

    validation_tasks = [
        (worker.model.compute_weights_from_seeds(noise, 
        validation_population[x].seeds, cache=cached_parents), 
        validation_population[x].seeds) for x in range(
                             config['validation_threshold'])]
    _,population_validation, population_validation_len =\ 
        zip(*worker.monitor_eval_repeated(validation_tasks, 
        max_frames=state.tslimit * 4, 
        num_episodes=config['num_validation_episodes']))
  1. 使用前 10 个顶级个体的评估结果,我们选择种群中的精英,并对其执行最终测试运行以评估其性能:
    population_elite_idx = np.argmax(population_validation)
    state.elite = validation_population[population_elite_idx]
    elite_theta = worker.model.compute_weights_from_seeds(
              noise, state.elite.seeds, cache=cached_parents)
    _,population_elite_evals,population_elite_evals_timesteps=\
                  worker.monitor_eval_repeated(
                  [(elite_theta, state.elite.seeds)], 
                  max_frames=None, 
                  num_episodes=config[‘num_test_episodes’])[0]

精英个体将直接复制到下一代。

  1. 最后,我们从当前种群中选择顶级个体作为下一代的父母:
    if config['selection_threshold'] > 0:
        tlogger.info("Caching parents")
        new_parents = []
        if state.elite in \
            state.population[:config['selection_threshold']]:
            new_parents.extend([
                 (worker.model.compute_weights_from_seeds(
                  noise, o.seeds, cache=cached_parents), o.seeds) for o in state.population[:config['selection_threshold']]])
        else:
            new_parents.append(
                (worker.model.compute_weights_from_seeds(
                 noise, state.elite.seeds, cache=cached_parents), 
                 state.elite.seeds))
            new_parents.extend([
                (worker.model.compute_weights_from_seeds(
                 noise, o.seeds, cache=cached_parents), o.seeds) for o in state.population[:config[‘selection_threshold']-1]])

上述代码从种群中收集顶级个体成为下一代的父母。如果当前精英不在父母列表中,它还会将其附加到父母列表中。

现在,我们准备好讨论如何运行实验。

运行 Frostbite Atari 实验

既然我们已经讨论了实验实施的全部细节,现在是时候运行实验了。然而,我们首先需要做的是创建一个合适的工作环境,我们将在下一节讨论这一点。

设置工作环境

训练代理玩 Atari 游戏的工作环境假设在过程中需要训练一个大型控制器人工神经网络。我们之前已经提到,控制器人工神经网络有超过 400 万个训练参数,需要大量的计算资源才能进行评估。幸运的是,现代 GPU 加速器允许同时执行大规模并行计算。这一特性对我们实验来说很方便,因为我们需要在进化过程中多次将每个个体与游戏环境进行评估。如果没有 GPU 加速,要么会花费很多时间,要么需要大量的处理核心(大约 720 个)。

让我们讨论如何准备工作环境:

  1. 工作环境需要系统中有 Nvidia 视频加速器(例如 GeForce 1080Ti)以及安装了适当的 Nvidia CUDA SDK。有关 CUDA SDK 及其安装的更多详细信息,请见 https://developer.nvidia.com/cuda-toolkit

  2. 接下来,我们需要确保已安装 CMake 构建工具,具体描述请见 https://cmake.org

  3. 现在,我们需要使用 Anaconda 创建一个新的 Python 环境,并安装实验实现中使用的所有依赖项:

$ conda create -n deep_ne python=3.5
$ conda activate deep_ne
$ conda install -c anaconda tensorflow-gpu
$ pip install gym
$ pip install Pillow

这些命令创建并激活一个新的 Python 3.5 环境。接下来,它安装 TensorFlow、OpenAI Gym 和 Python 图像库作为依赖项。

  1. 之后,您需要克隆包含实验源代码的仓库:
$ git clone https://github.com/PacktPublishing/Hands-on-Neuroevolution-with-Python.git
$ cd Hands-on-Neuroevolution-with-Python/Chapter10

执行这些命令后,我们的当前工作目录变成了包含实验源代码的目录。

  1. 现在,我们需要构建 ALE 并将其集成到我们的实验中。我们需要将 ALE 仓库克隆到适当的目录,并使用以下命令进行构建:
$ cd cd gym_tensorflow/atari/
$ git clone https://github.com/yaricom/atari-py.git
$ cd ./atari-py && make

现在,我们已经有一个与TensorFlow集成的可工作的ALE环境。我们可以用它来评估从基因组种群中产生的控制器ANN,与Atari游戏(在我们的实验中为Frostbite)进行对抗。

  1. 在ALE集成完成后,我们需要构建一个针对我们实验实现的特定于OpenAI Gym和TensorFlow的集成:
$ cd ../..gym_tensorflow && make

现在,我们已经完全定义了工作环境,并准备好开始我们的实验。接下来,我们将讨论如何运行实验。

运行实验

在一个充分定义的工作环境中,我们准备好开始我们的实验。您可以通过执行以下命令从Chapter10目录启动实验:

$ python ga.py -c configurations/ga_atari_config.json -o out

之前的命令使用提供的精英基因组启动了一个实验,该实验使用作为第一个参数提供的配置文件。实验的输出将存储在out目录中。

实验完成后,控制台输出应类似于以下内容:

...
| PopulationEpRewMax                    | 3.47e+03  |
| PopulationEpRewMean                   | 839       |
| PopulationEpCount                     | 1e+03     |
| PopulationTimesteps                   | 9.29e+05  |
| NumSelectedIndividuals                | 20        |
| TruncatedPopulationRewMean            | 3.24e+03  |
| TruncatedPopulationValidationRewMean  | 2.36e+03  |
| TruncatedPopulationEliteValidationRew | 3.1e+03   |
| TruncatedPopulationEliteIndex         | 0         |
...
| TruncatedPopulationEliteTestRewMean   | 3.06e+03  |
...
 Current elite: (47236580, (101514609, 0.002), (147577692, 0.002), (67106649, 0.002), (202520553, 0.002), (230555280, 0.002), (38614601, 0.002), (133511446, 0.002), (27624159, 0.002), (233455358, 0.002), (73372122, 0.002), (32459655, 0.002), (181449271, 0.002), (205743718, 0.002), (114244841, 0.002), (129962094, 0.002), (24016384, 0.002), (77767788, 0.002), (90094370, 0.002), (14090622, 0.002), (171607709, 0.002), (147408008, 0.002), (150151615, 0.002), (224734414, 0.002), (138721819, 0.002), (154735910, 0.002), (172264633, 0.002)) 

在这里,我们有特定进化代次后的统计数据输出。您可以看到以下结果:

  • 在评估种群时,所达到的最大奖励分数为3,470 (PopulationEpRewMax)。

  • 在额外30个验证集的顶尖个人中,所达到的最高分数为3,240 (TruncatedPopulationRewMean)。

  • 顶尖个人评估的*均分数为2,360 (TruncatedPopulationValidationRewMean)。

  • 在额外的200次测试运行中获得的精英个人*均分数为3,060 (TruncatedPopulationEliteTestRewMean)。

如果我们查看在https://arxiv.org/abs/1712.06567v3上发布的成果,与其他训练方法相比,所获得的奖励分数相当高。

此外,在输出的末尾,您可以看到种群精英的基因组表示。精英基因组可以用来通过从它创建的表型ANN来可视化玩Frostbite。接下来,我们将讨论如何实现这种可视化。

Frostbite可视化

现在我们已经获得了游戏代理训练的结果,将很有趣地看到我们找到的解决方案如何在Atari环境中玩Frostbite。要运行模拟,您需要从输出中复制当前的精英基因组表示并将其粘贴到display.py文件的seeds字段中。之后,可以使用以下命令运行模拟:

$ python display.py

之前的命令使用提供的精英基因组创建一个表型ANN,并将其用作Frostbite游戏代理的控制器。它将打开游戏窗口,您可以在其中看到控制器ANN的表现。游戏将继续进行,直到游戏角色没有剩余的生命。以下图像显示了在Ubuntu 16.04环境中执行display.py时捕获的几个游戏屏幕:

Frostbite截图,所有截图均来自精英基因组游戏会话

看到训练后的控制器ANN仅从视觉观察中学习游戏规则,并能展示出如此流畅的游戏操作,真是令人惊叹。

接下来,我们将讨论一种额外的可视化方法,它允许我们分析结果。

神经进化可视化检查器

在神经进化过程中,我们正在进化一个个体群体。每个个体都会在测试环境(如Atari游戏)中进行评估,并为每个进化代收集每个个体的奖励分数。为了探索神经进化过程的一般动态,我们需要一个工具来可视化每个进化代每个个体的结果云。同时,观察精英个体的适应度分数变化,有助于理解进化过程的进展。

为了满足这些要求,Uber AI的研究人员开发了VINE工具,我们将在下一部分讨论。

设置工作环境

要使用VINE工具,我们需要使用以下命令在我们的虚拟Python环境中安装额外的库:

$ pip install click
$ conda install matplotlib
$ pip install colour
$ conda install pandas

这些命令将所有必要的依赖项安装到我们为实验创建的虚拟Python环境中。接下来,我们将讨论如何使用VINE工具。

在运行前面的命令之前,不要忘记使用以下命令激活适当的虚拟环境:conda activate deep_ne

使用VINE进行实验可视化

现在,当我们在Python虚拟环境中安装了所有依赖项后,我们就可以使用VINE工具了。首先,你需要使用以下命令从Git仓库克隆它:

$ git clone https://github.com/uber-research/deep-neuroevolution.git
$ cd visual_inspector

在这里,我们将深度神经进化仓库克隆到当前目录,并将目录更改为包含VINE工具源代码的visual_inspector文件夹。

让我们讨论一下如何使用VINE工具来可视化Uber AI Lab提供的Mujoco Humanoid实验结果。更多关于Mujoco Humanoid实验的详细信息可以在https://eng.uber.com/deep-neuroevolution/找到。

现在,我们可以使用以下命令运行Mujoco Humanoid实验结果的可视化,这些结果包含在sample_data文件夹中:

$ python -m main_mujoco 90 99 sample_data/mujoco/final_xy_bc/

前一个命令使用了Uber AI Lab从其实验中提供的用于训练类人行走的数据,并显示了以下图表:

VINE工具对类人行走结果的可视化

在图表的左侧,你可以看到种群中每个个体在90代到99代之间的结果云。在图表的右侧,你可以看到每一代种群精英的适应度分数。在右侧的图表中,你可以看到进化过程展示了从一代到下一代适应度分数增加的积极动态。

左侧图表上的每个点都代表种群中每个个体的行为特征点。人形运动任务的行为特征是轨迹结束时人形的最终位置。它离原点坐标(0,0)越远,个体的适应度分数就越高。你可以看到,随着进化的进展,结果云正远离原点坐标。这种结果云的移动也是积极学习动态的信号,因为每个个体都能够保持更长时间的*衡。

关于Mujoco人形运动实验的更多详细信息,请参阅https://eng.uber.com/deep-neuroevolution/上的文章。

练习

  1. 尝试增加实验中的population_size参数,看看会发生什么。

  2. 尝试创建实验结果,这些结果可以使用VINE进行可视化。你可以使用ga.py脚本中的master_extract_parent_gamaster_extract_cloud_ga辅助函数来完成此操作。

摘要

在本章中,我们讨论了如何使用神经进化来训练具有超过400万个可训练参数的大型ANN。你学习了如何应用这种方法来创建能够仅通过观察游戏屏幕学习游戏规则的成功代理,从而玩经典Atari游戏。通过完成本章中描述的Atari游戏实验,你了解了卷积神经网络(CNNs)以及它们如何将高维输入,如游戏屏幕观察,映射到适当的游戏动作。你现在对CNNs在深度强化学习(deep RL)方法中的价值函数*似有了坚实的理解,该方法由深度神经进化算法指导。

通过本章获得的知识,你将能够将深度神经进化方法应用于具有高维输入数据域,例如从摄像头或其他图像源获取的输入。

在下一章中,我们将总结本书所涵盖的内容,并提供一些关于你如何继续自我教育的提示。

第十四章:第4节:讨论与结语

在本节中,我们为您总结了本书所学内容,并指引您前往可以学习更多关于基于神经演化的算法的资源。

本节包含以下章节:

第十五章:最佳实践、技巧与窍门

在本章中,我们提供了一些关于编写和分析神经进化算法的最佳实践、技巧和窍门的建议。到本章结束时,你将了解如何开始处理手头的问题,如何调整神经进化算法的超参数,如何使用高级可视化工具,以及可以使用哪些指标来分析算法的性能。此外,你还将了解 Python 的最佳编码实践,这有助于你在项目实施中。

在本章中,我们将涵盖以下主题:

  • 从问题分析开始

  • 选择最佳搜索优化方法

  • 使用高级可视化工具

  • 调整超参数并了解应调整的内容

  • 理解需要收集哪些性能指标

  • Python 编码技巧与窍门

从问题分析开始

从对问题空间的适当分析开始是成功的关键。神经进化对程序员错误宽容。这些错误是环境的一部分,进化过程可以适应它们。然而,有一种特定的错误类别可能会阻碍进化过程找到成功解决方案:进化过程的数值稳定性。大多数类型的激活函数都设计在零到一之间的输入范围内运行。因此,过大或负的值对进化过程的影响不大。

因此,你可能需要预处理输入数据以避免这些数值问题。不要跳过对输入数据样本和分析步骤的分析。

接下来,我们将讨论如何预处理输入数据。

预处理数据

总是检查可能的数据输入范围并查找异常值。如果你发现某个输入参数的规模与其他参数相差一个数量级,你需要预处理输入数据样本。否则,具有更高幅度的输入数据特征将对训练过程产生如此显著的影响,以至于它们最终会超过其他输入数据特征的贡献。然而,由小幅度数据输入产生的微小信号通常对于找到成功解决方案至关重要。微妙的输入信号可以表征底层过程中的微妙但宝贵的特性。

数据标准化

大多数机器学习算法都从正态分布的输入数据中受益;也就是说,它具有零均值和单位方差。将输入数据缩放到具有零均值和单位方差的一般方法如下公式所示:

图片

注意, 是缩放后的输入分数, 是输入数据样本, 是训练样本的*均值,而 是训练样本的标准差。

你可以使用 Scikit-learn Python 库对你的输入数据样本应用标准缩放。以下源代码是这一点的示例:

>>> from sklearn.preprocessing import StandardScaler
>>> data = [[0, 0], [0, 0], [1, 1], [1, 1]]
>>> scaler = StandardScaler()
>>> print(scaler.fit(data))
StandardScaler(copy=True, with_mean=True, with_std=True)
>>> print(scaler.mean_)
[0.5 0.5]
>>> print(scaler.transform(data))
[[-1\. -1.]
 [-1\. -1.]
 [ 1\. 1.]
 [ 1\. 1.]]

在代码中,我们首先创建输入数据样本。之后,使用 StandardScaler 对输入样本进行居中和缩放。数据转换的结果显示在代码的最后几行。

数据预处理的另一种方法是缩放特征以适应特定范围,我们将在下一节中讨论。

将输入缩放到特定范围

将输入缩放到适应特定范围是数据预处理的另一种方法。这种方法是标准化的替代方案。范围缩放产生位于最小值和最大值之间给定范围内的数据样本。通常,这种方法用于将输入数据缩放到零和一之间的范围。你可以使用 Scikit-learn Python 库中的 MinMaxScaler 来缩放数据,如下例所示:

>>> import sklearn.preprocessing
>>> X_train = np.array([[ 1., -1., 2.],
... [ 2., 0., 0.],
... [ 0., 1., -1.]])
...
>>> min_max_scaler = preprocessing.MinMaxScaler()
>>> X_train_minmax = min_max_scaler.fit_transform(X_train)
>>> X_train_minmax
array([[0.5 , 0\. , 1\. ],
       [1\. , 0.5 , 0.33333333],
       [0\. , 1\. , 0\. ]])

代码从创建一个样本数据集开始,并使用 MinMaxScaler 类对其进行转换。在最终输出中,你可以看到范围缩放转换的结果。

有时,你需要具有相同单位的数据样本。这种预处理类型被称为归一化。我们将在下一节中讨论它。

数据归一化

通常,你的输入数据特征有不同的度量单位。例如,在*衡杆实验中,小车位置以米为单位测量,线性速度以每秒米为单位,角速度以每秒弧度为单位。将输入数据归一化以简化输入数据特征之间的比较是有益的。

归一化过程有效地从输入数据样本中消除了度量单位。之后,所有样本都将位于零和一之间。

统计学中有不同类型的归一化。我们已提到两种方法:数据标准化和数据范围缩放。此外,Scikit-learn 提供了一个专门的转换器来执行数据归一化,它将单个样本缩放到所谓的单位范数。以下代码演示了如何使用它:

>>> import sklearn.preprocessing
>>> X = [[ 1., -1., 2.],
... [ 2., 0., 0.],
... [ 0., 1., -1.]]
>>> X_normalized = preprocessing.normalize(X, norm='l2')
>>> X_normalized 
array([[ 0.40..., -0.40..., 0.81...],
       [ 1\. ..., 0\. ..., 0\. ...],
       [ 0\. ..., 0.70..., -0.70...]])

代码创建测试数据样本,并使用l2范数对其进行归一化,然后输出结果。

Scikit-learn 库提供了许多其他数据预处理方法的实现。熟悉它们对你会有所帮助。你可以在 https://scikit-learn.org/stable/modules/preprocessing.html 找到一篇优秀的教程。

理解问题域

在本书中,我们讨论的一些实验与物理世界的真实过程有关。为了找到此类过程的成功解决方案,您需要理解其背后的物理定律和原理。例如,*衡小车-杆装置的问题要求我们定义一组完整的运动方程,以编写准确的任务模拟器。

对于机器人领域的许多任务,您还需要编写一个使用底层装置的正确物理模型和方程的模拟器。您需要完全理解该过程的物理,以正确实现模拟器。即使您使用现成的模拟器,理解其中实现的物理原理对您来说也是极其有益的,因为理解现实世界过程的动力学允许您适当地调整训练算法的超参数。

编写良好的模拟器

当处理特定问题时,编写一个正确实现模拟过程具体细节的适当模拟器至关重要。如果您使用这样的模拟器,您将能够运行长时间的训练阶段,而使用来自物理设备的直接输入则不可能实现这一点。

一个好的模拟器应该允许您控制模拟过程的单步时间长度。在神经进化过程中,您需要将种群中的每个个体与给定的模拟器进行比较。因此,在训练过程中,使单步时间尽可能短以提高执行速度是有意义的。另一方面,当找到解决方案并需要手动测试时,如果能够以正常执行速度运行模拟器,将是有益的。

此外,您还可以使用现有的成熟模拟器来完成您的项目,这可以为您节省大量时间。熟悉那些经过良好建立的开源模拟器包。它们通常提供高级物理模拟以及为您的虚拟机器人和环境提供的预制的构建块集合。您可以从https://github.com/cyberbotics/webots开始搜索。

接下来,我们将讨论如何为您的实验选择正确的搜索优化方法。

选择最佳搜索优化方法

在本书中,我们向您介绍了两种基本的搜索优化方法:目标导向搜索和新颖性搜索。前者方法更易于实现和理解。然而,新颖性搜索在适应度函数具有许多局部最优陷阱的欺骗性景观的情况下非常有用。

在下一节中,我们将简要讨论这两种方法,以提醒您细节并帮助您在特定情况下选择使用哪一种。我们首先从目标导向搜索开始。

目标导向搜索优化

目标导向搜索优化基于测量解决方案与最终目标之间的接*程度。为了计算到目标的*均距离,它通常使用均方误差等度量。接下来,我们将讨论均方误差度量的具体细节。

均方误差

均方误差是获得的结果与实际值之间*均*方差的*均值。它由以下公式给出:

图片

这里图片是估计值,而图片是实际值。

我们使用均方误差的变体来定义XOR实验的目标函数。接下来,我们将讨论与欧几里得空间中的定位相关问题的目标导向度量。

欧几里得距离

欧几里得距离是适用于与欧几里得问题空间中的导航相关的任务的适当度量。在欧几里得问题空间中,我们将问题目标定义为具有特定坐标的点。

使用欧几里得距离,可以轻松计算导航代理位置与其试图到达的目标点之间的距离。以下公式计算两个向量之间的欧几里得距离:

图片

这里图片是具有代理位置图片的向量与具有代理最终目标的向量图片之间的欧几里得距离。我们使用这个度量来定义第5章“自主迷宫导航”中代理在迷宫中导航的目标函数。

然而,自主迷宫导航的问题是由欺骗性的适应度函数景观引起的,这使得目标导向搜索优化效率低下。接下来,我们将讨论新颖搜索优化方法,它能够解决这种低效问题。

新颖搜索优化

正如我们提到的,通过迷宫导航是一个具有欺骗性的问题,需要不同的方法来定义适应度函数。在第5章“自主迷宫导航”中,我们向您展示了一种特定的迷宫配置,该配置产生具有目标导向适应度分数强局部最优的区域。因此,训练过程可能会被困在这些区域中,并无法产生成功的解决方案。新颖搜索优化方法被设计用来解决欺骗性适应度函数景观的问题。

新颖性搜索奖励的是解决方案的新颖性,而不是其与最终目标的接*程度。此外,用于计算每个解决方案适应度分数的新颖性指标,完全忽略了解决方案与最终目标的接*程度。有两种流行的计算新颖性分数的方法:

  • 新颖性分数是从解决方案架构的差异中计算出来的。

  • 新颖性是通过在共同行为空间中解决方案行为的独特变化来计算的。

前者计算当前解决方案编码与所有先前解决方案编码之间的差异。后者将当前解决方案在行为空间中产生的结果与其他解决方案产生的结果进行比较。

我们使用基于暴露行为独特性的新颖性分数来定义迷宫求解器的适应度函数。迷宫求解器在迷宫中的轨迹完全决定了代理的行为空间,并可用于计算新颖性分数。在这种情况下,新颖性分数是当前解决方案轨迹向量和所有其他解决方案轨迹向量之间的欧几里得距离。

现在我们已经讨论了选择适当的搜索优化方法的重要性,我们可以继续讨论实验成功的另一个重要方面。你需要对实验结果有一个良好的可视化,以便对其性能有深入了解。接下来,我们将讨论结果的可视化。

高级可视化

几乎总是,正确地可视化输入和结果对于实验的成功至关重要。有了正确的可视化,你将获得关于出了什么问题以及需要修复什么的直观见解。

总是尝试可视化模拟器执行环境。这种可视化可以在你得到意外结果时节省你数小时的调试时间。通常,有了足够的可视化,你可以一眼看出哪里出了问题,例如一个在角落里卡住的迷宫求解器。

在使用神经进化算法时,你还需要可视化每一代的遗传算法执行性能。你需要从一代到一代地可视化物种形成,以查看进化过程是否已经停滞。停滞的进化无法创造出足够的物种来维持求解器之间的健康多样性。另一方面,过多的物种通过减少不同生物之间繁殖的机会来阻碍进化。

另一个重要的可视化使我们能够看到产生的表型人工神经网络ANN)的拓扑结构。检查产生的解决方案的拓扑结构是否满足我们的期望是有用的。例如,当我们讨论第 8 章模块化视网膜问题时,ES-HyperNEAT 和视网膜问题,看到模块化结构在成功解决方案的拓扑结构中进化是有益的。

您需要熟悉标准的 Python 科学绘图库,以便为您的实验结果创建足够的可视化。掌握像 Matplotlib (https://matplotlib.org) 和 Seaborn (https://seaborn.pydata.org) 这样的可视化库的实用技能是至关重要的。

接下来,我们讨论超参数调整对神经进化过程性能的重要性。

调整超参数

通过适当调整超参数,您可以极大地提高神经进化过程的训练速度和效率。以下是一些实用技巧:

  • 使用不同的随机数生成器种子值进行短时间运行,并注意算法性能的变化。之后,选择给出最佳性能的种子值,并用于长时间运行。

  • 您可以通过降低兼容性阈值和略微增加不交/过剩权重系数的值来增加种群中的物种数量。

  • 如果神经进化过程在尝试找到解决方案时遇到了困难,尝试降低 NEAT 生存阈值的值。这个系数维持了种群中最佳生物体繁殖的机会比例。通过这样做,您可以根据其适应度分数提高允许繁殖的个体的质量。

  • 通过增加最大停滞年龄,您可以确保物种有足够的时间在进化的后期阶段引入有益的突变。有时,这样的操作可以帮助恢复停滞的神经进化过程。然而,您应该始终尝试使用小的停滞年龄值(15-20)来启动物种的快速轮换,并且只有在所有其他调整都失败的情况下才显著增加此参数。

  • 在调整超参数后,进行几十代的短时间运行,以观察性能变化动态。特别关注物种数量——种群中至少应该有多个物种。物种过多也是一个不良信号。通常,5 到 20 个物种是一个良好的范围。

  • 使用实验结果的可视化来快速了解实验的性能。永远不要错过可视化发现解决方案的ANN拓扑结构的机会。这些可视化可以给你提供无价的见解,告诉你如何调整神经进化过程。

  • 不要浪费时间去进行长时间的进化运行。如果实验在1,000代内未能找到成功解决方案,那么很可能你的代码或你使用的库存在问题。对于大多数简单问题,成功解决方案甚至可以在100代内找到。

  • 种群大小是进化过程中的一个关键参数。在过程一开始,较大的种群就能带来很大的多样性,从而推动过程的发展。然而,大种群难以计算。因此,种群大小和计算成本之间总是存在权衡。作为一个经验法则,如果你在寻找其他合适的超参数时遇到困难,尝试增加种群大小,看看是否有所帮助。但要做好准备,等待额外的神经进化过程完成时间。

  • 总是打印调试信息,这样你可以从评估的任何阶段重新启动实验。当你计算两天后发现解决方案,但由于一些编程错误,你的程序在尝试输出祝贺信息时崩溃,这总是令人痛苦。你需要在每个试验的开始至少输出随机种子值。这可以保证在失败的情况下,你可以准确地重新创建所有进化的代数。

不要低估超参数调整的重要性。即使考虑到神经进化过程可以处理许多编程错误,选择正确的超参数也可以显著提高过程的效率。结果,你将能够在数百代而不是数千代或更多代内找到成功解决方案。

为了比较不同解决方案的性能,你需要使用适当的性能指标,我们将在下面讨论。

性能指标

在找到成功解决方案之后,将其与其他解决方案进行比较以估计其好坏至关重要。有许多重要的统计指标可以比较不同的模型。

熟悉精度分数、召回分数、F1分数、ROC AUC和准确率等概念。理解这些指标将帮助你比较不同模型在各种分类任务中产生的结果。接下来,我们将简要概述这些指标。

精度分数

精度分数试图回答有多少个阳性识别实际上是正确的。精度分数可以按以下方式计算:

图片

TP是真正阳性,FP是假阳性。

回忆分数

召回度分数回答了有多少实际正样本被正确识别的问题。召回度分数可以用以下公式给出:

图片

TP是真正例,FN是假负例。

F1分数

F1分数是精确度和召回度分数的加权*均值。F1分数的最佳值是1,最差值是0。F1分数允许测量特定类别的分类准确度。它可以定义为以下:

图片

这里图片是精确度分数,而图片是与特定正类相关的召回度分数。

在下一节中,我们将探讨接收者操作特征ROC)曲线和曲线下面积AUC)。

ROC AUC

我们通过在不同阈值下绘制真正例率与假正例率来创建ROC曲线。它显示了分类模型在不同阈值下的性能。

真正例率TPR)是召回的同义词,我们之前讨论过。它可以由以下公式给出:

图片

假正例率的计算方法如下:

图片

TN代表真正例。

AUC允许我们估计分类模型的判别能力,即模型正确对随机正样本点进行排序高于随机负样本点的能力。以下是一个ROC曲线的示例:

图片

ROC曲线示例

在图中,你可以看到ROC曲线的示例。AUC越大,分类器模型越准确。虚线表示最差的分类器准确度。一般来说,ROC曲线越接*左上角,分类模型的性能越好。

准确度

准确度是一个衡量我们的模型能够产生多少正确预测的指标。准确度由以下公式给出:

图片

FP代表假正例,FN代表假负例。

更多关于描述的指标详情可以在https://scikit-learn.org/stable/auto_examples/model_selection/plot_precision_recall.html找到。

接下来,我们讨论Python编码技巧。

Python编码技巧和窍门

决定使用Python后,学习该语言的最佳编码实践至关重要。在这里,我们为您提供一些建议,并指导您继续自学。

编码技巧和窍门

以下编码技巧和窍门将帮助您掌握Python:

  • 您应该学习如何使用流行的机器学习库,例如 NumPy (https://numpy.org)、pandas (https://pandas.pydata.org) 和 Scikit-learn (https://scikit-learn.org/stable/)。掌握这些库将在数据处理和分析方面给您带来巨大的力量。这将帮助您避免许多错误,并使从实验中收集的结果易于调试。

  • 了解面向对象的编程范式。这将使您能够编写清晰且易于维护的源代码,易于理解。您可以从 https://www.datacamp.com/community/tutorials/python-oop-tutorial 开始。

  • 不要将所有内容都写在一个巨大的函数中。将您的代码分解成更小的可重用块,实现为函数或类,这些函数或类可以在多个项目中重用,并且易于调试。

  • 打印相关的调试输出以了解您的实现中正在发生什么。足够的调试输出允许您了解执行过程中出现的问题。

  • 在您的源代码中编写与函数、类和复杂位置相关的注释。好的注释可以显著帮助代码的可理解性。在开始实现之前编写注释也有助于您澄清思路。

  • 在给函数编写注释时,描述所有输入和输出参数及其默认值(如果有的话)。

  • 如果你决定继续使用 Python,花些时间学习 Python 的标准库。Python 是一种成熟的编程语言,其中包含了许多嵌入到其标准库中的实用函数。它还提供了许多高级数据处理功能,这些功能可用于机器学习任务。更多关于标准 Python 库的详细信息可以在 https://docs.python.org/3/library/index.html 找到。

  • 在给变量和类命名时,遵循标准的 Python 源代码约定。遵循标准命名约定可以使您的代码对熟悉 Python 的人来说更易于阅读和理解。更多详细信息可以在 https://docs.python-guide.org/writing/style/https://www.python.org/dev/peps/pep-0008/ 找到。

  • 使自己熟悉现代版本控制系统,如 Git。版本控制系统VCS)是您可用的强大工具,可能会为您节省数小时甚至数天的时间,以恢复因硬盘故障而丢失的工作。您可以在 https://github.github.com/training-kit/downloads/github-git-cheat-sheet.pdfhttps://www.atlassian.com/git/tutorials 了解 Git。

  • 了解在线代码仓库,例如GitHub (https://github.com) 和 Bitbucket (https://bitbucket.org),在这些*台上你可以分享你的源代码并研究其他数据科学家的源代码。

编写良好实现的重要方面之一是正确设置工作环境并使用适当的编程工具。

工作环境和编程工具

总是使用成熟的Python包管理器之一,如Anaconda Distribution,来正确设置你的工作环境是个好主意。作为额外的好处,你将获得大量免费的科学和机器学习包,这些包可以一键安装。此外,Anaconda Distribution处理所有间接依赖项的管理,并帮助你保持所有包的最新状态。你可以在https://www.anaconda.com/distribution/找到Anaconda Distribution。

在每个实验中始终为你的实验创建一个新的虚拟Python环境。之后,如果依赖项出现问题,你将能够通过一条命令清理一切并从头开始。可以使用以下方式使用Anaconda Distribution创建新的Python环境:

$ conda create --name <name>
$ conda activate <name>

在创建新环境时,始终指定你计划在其中使用的确切Python版本。提供确切版本将帮助你避免由不兼容性引起的大量意外。可以按以下方式为新环境定义Python版本:

$ conda create --name <name> python=3.5

如果你需要在项目中使用新的依赖项,首先检查Anaconda Cloud中是否存在适当的安装包。通过使用Anaconda Cloud中的库,你可以避免间接依赖安装的问题。此外,一些框架,如TensorFlow,需要安装额外的系统驱动程序和头文件。这项任务可能非常繁琐,并需要额外的专业知识。

使用支持代码补全、文档浏览和维护虚拟Python环境的良好代码编辑器。一个好的起点是Visual Studio Code——由微软提供的免费编辑器。你可以在https://code.visualstudio.com找到它。

使自己熟悉现代Linux系统,如Ubuntu。大多数机器学习库在Linux上使用起来都更容易。这对于使用GPU加速的库尤其如此。有关Ubuntu及其安装的更多详细信息,请参阅https://ubuntu.com

摘要

在本章中,我们为你提供了实用的技巧,希望这些技巧能让你生活更轻松。你了解了数据预处理的标准化方法,以及可以用来评估你创建的模型性能的传统统计指标。最后,你学习了如何提高你的编码技能以及在哪里寻找有关Python和机器学习主题的更多信息。

在下一章中,我们将回顾一些基于我们在书中所学到的内容以及我们将来可以在哪里应用我们所学概念的总结性评论。

第十六章:结论

在本章中,我们将总结本书所学的一切,并提供进一步的信息,以便你可以继续自学。本章将帮助我们以章节形式回顾所涵盖的主题,并通过分享Uber AI Labs、alife.org和Reddit上的开放式进化的一些细节来提供路线图。我们还将快速概述NEAT软件目录和NEAT算法论文。

在本章中,我们将涵盖以下主题:

  • 本书所学内容

  • 接下来该做什么

本书所学内容

现在我们已经完成了实验,我希望你已经对人工神经网络神经进化的方法有了坚实的理解。我们使用神经进化来解决各种实验,从经典的计算机科学问题到能够玩Atari游戏的智能体的创建。我们还检查了与计算机视觉和视觉辨别相关的任务。

在本节中,我们将总结本书每一章所学的内容。

神经进化方法概述

在本章中,我们学习了遗传算法的核心概念,如遗传算子和基因组编码方案。

我们讨论了两种主要的遗传算子,允许我们维持进化过程:

  • 突变算子实现了后代的随机突变,这为种群引入了遗传多样性。

  • 交叉算子通过从每个父代中采样基因来生成后代。

之后,我们继续讨论选择合适的基因组编码方案的重要性。我们考虑了两种主要的编码格式:直接和间接基因组编码。前者在基因组与编码的表型人工神经网络(ANN)之间建立了一对一的关系。通常,直接编码用于编码小型ANN,其连接节点数量有限。更先进的间接编码方案允许我们编码大型网络的演变ANN拓扑结构,通常具有数百万个连接。间接编码允许我们重用重复的编码块,从而显著减少基因组的尺寸。

一旦我们熟悉了现有的基因组编码方案,我们就继续讨论神经进化方法,该方法使用不同的编码方案。我们首先介绍了NEAT算法,它使用直接基因组编码方案,并通过创新数字概念进行增强。与每个基因型基因相关的创新数字提供了一种精确追踪特定突变何时被引入的手段。这一特性使得两个父母之间的交叉操作变得简单易行。NEAT方法强调从非常基础的基因组开始,在进化过程中逐渐变得更加复杂的重要性。这样,进化过程有很好的机会找到最优解。

此外,引入了物种形成概念,通过将有益突变隔离在特定物种(生态位)中,从而保留这些突变。同一生态位内的物种只允许彼此之间进行杂交。物种形成是自然进化的强大推动力,并且已经证明它对神经进化也有重大影响。

在讨论了基本的NEAT算法之后,我们继续讨论其衍生算法,以解决原始算法的局限性。NEAT算法的一个显著缺点是由于使用了直接基因组编码方案。虽然这种方案易于可视化和实现,但它只能编码表型ANN的小拓扑结构。随着表型ANN大小的增加,基因组的大小以线性比例增加。这种基因组大小的线性增加最终使得维护变得困难。因此,为了解决这些缺点,引入了一系列基于间接基因组编码方案的扩展,例如HyperNEAT和ES-HyperNEAT。

HyperNEAT方法使用一种高级格式来表示表型ANN节点之间的连接,这种格式以超立方体中的四维点形式出现。所选超立方体的维度是基于这样一个事实:在ANN内部两个节点之间的连接可以通过称为基质的介质中连接端点的坐标进行编码。基质拓扑提供了一个框架,用于在表型ANN的节点之间绘制连接。在基质中两个特定节点之间绘制的连接强度是通过称为组合模式生成网络CPPN)的辅助神经网络来估计的。CPPN接收超点(连接端点的坐标)的坐标作为输入,并计算连接的强度。此外,它还计算标志值,该值指示是否应该表达连接。实验者预先定义基质配置。它由要解决的问题的几何属性定义。同时,在神经进化过程中,使用NEAT算法进化CPPN的拓扑结构。因此,我们得到了两者的最佳结合。NEAT算法的力量使我们能够进化最优的CPPN配置。同时,CPPN通过间接编码方案保持了编码方案,使我们能够表示大型表型ANN。

ES-HyperNEAT方法通过提出一种与连接CPPN进化相当的高级基质进化方法,进一步增强了原始的NEAT和HyperNEAT方法。基质进化建立在信息密度概念的基础上,这允许在信息变异性较高的区域进行更密集的节点放置。这种方法允许神经进化过程发现基质配置,这些配置精确地遵循由要解决的问题暴露的几何规律。

我们在第一章的结尾讨论了名为新颖性搜索NS)的迷人的搜索优化方法。这种方法基于使用找到的解决方案的新颖性来估计的准则来引导进化搜索。传统上,搜索优化基于目标导向的适应度准则,这些准则衡量我们离目标有多*。但是,存在一个广泛的现实世界问题,它们具有欺骗性的适应度函数景观,这引入了强烈的局部最优陷阱。目标导向的搜索有很大可能性陷入这些陷阱,无法找到最终解决方案。同时,奖励找到的解决方案新颖性的搜索优化方法,使我们能够通过完全忽略最终目标附*的距离来避免这些陷阱。NS方法已被证明在通过欺骗迷宫环境进行自主导航的任务中是有效的;它优于基于目标的方法。

在本书的下一章中,我们讨论了如何正确设置工作环境以及可以使用哪些Python库来实验神经进化。

Python库和环境设置

在本章中,我们首先讨论了神经进化方法的实际方面。我们讨论了提供NEAT算法及其扩展实现的流行Python库的优缺点。

除了每个Python库的亮点之外,我们还提供了小的代码片段,让你感受到如何在实验中使用每个特定的库。

之后,我们继续讨论如何正确设置工作环境。工作环境必须安装必要的依赖项,以便使用所提到的Python库。安装可以通过几种方法完成。我们考虑了两种最常见的方法——Python的标准包安装器PIP)实用工具和Anaconda发行版。工作环境准备的关键方面之一是为每个特定实验创建隔离的虚拟Python环境。虚拟环境提供了为不同实验组合和其中使用的NEAT Python库的不同依赖配置带来好处。

在虚拟环境中隔离依赖项也允许轻松管理所有安装的依赖项作为一个整体。你可以快速从你的电脑上删除环境,包括其中安装的所有内容,从而释放磁盘空间。你也可以为不同的实验重用特定的虚拟环境,这取决于相同的NEAT实现库。

本章应该让你熟悉了开始神经进化实验所需的每一个工具。在下一章中,我们继续讨论使用基本NEAT算法的XOR求解器实验。

使用NEAT进行XOR求解器优化优化

这是第一章,我们开始尝试使用NEAT算法。我们通过实现一个经典计算机科学问题的求解器来完成这项工作。我们首先构建了一个用于XOR问题的求解器。XOR问题求解器是强化学习领域的一个计算机科学实验。XOR问题无法线性分离,因此需要求解器来找到非线性执行路径。然而,我们可以通过在ANN结构中引入隐藏层来找到非线性执行路径。

我们讨论了NEAT算法如何完美地满足这一需求,因为它固有的能力可以通过逐步复杂化从非常简单或复杂的拓扑结构中进化ANN。在XOR实验中,我们从一个由两个输入节点和一个输出节点组成的初始ANN拓扑结构开始。在实验过程中,发现了求解器ANN的相关拓扑结构,并引入了一个额外的隐藏节点来表示非线性,正如我们所预期的。

此外,我们还解释了如何定义一个合适的适应度函数来引导进化搜索,以及如何在Python脚本中实现它。我们非常关注描述用于XOR实验的NEAT-Python库性能微调的超参数。

在本章中,我们获得了实现基本求解器所需的基本计算机科学实验技能,并准备进行更高级的实验。

杆*衡实验

在本章中,我们继续在强化学习领域的计算机科学经典问题上进行实验。我们首先讨论了如何使用NEAT算法实现避免控制优化方法,使我们能够*衡小车-杆装置(或倒立摆)。我们从单杆*衡系统开始,提供了所有必要的运动方程,使我们能够数值*似现实世界的物理装置。

我们学习了如何将特定的控制动作以bang-bang控制器形式应用于小车-杆装置。bang-bang控制器是一种独特的控制系统,旨在连续地以相同的力量但不同的方向应用一系列动作。为了管理bang-bang控制器,控制器的ANN需要持续接收和分析小车-杆装置的状态,并产生相关的控制信号。系统的输入信号由小车在轨道上的水*位置、其线性速度、杆的当前角度和杆的角速度定义。系统的输出是一个二进制信号,指示需要应用的控制动作的方向。

神经进化过程使用滑车-杆装置的模拟来执行每个RL风格训练算法的特征性的试错过程。它维护着从一代到下一代进化的基因组种群,直到找到一个成功的求解器。在它们的进化过程中,种群中的每个生物都会被滑车-杆装置的模拟所测试。在模拟结束时,它会收到一个奖励信号,该信号以它在轨道范围内保持装置*衡的时间步数的形式出现。收到的奖励信号定义了生物的适应性,并决定了它在神经进化过程中的命运。

然后,我们讨论了如何使用提到的奖励信号来定义目标函数。之后,你学习了如何使用Python实现目标函数。

在完成单个极性*衡实验后,我们查看了一个修改后的实验版本。这个修改后的版本包括两个不同长度的杆连接到需要*衡的移动滑车上。这个实验具有更复杂的物理特性,并且在实验中需要发现一个更加复杂的控制器。

本章中提出的两个实验都强调了保持一个由适度数量的物种组成的*衡种群的重要性。种群中物种过多可能会通过减少不同物种的两个生物之间的繁殖机会来阻碍神经进化过程。此外,考虑到种群大小是固定的,种群中物种越多,其分布就越稀疏。稀疏分布的物种会降低发现有用突变的机会。另一方面,不同的物种使我们能够在每个物种形成生态位内保持有用的突变,并在下一代中进一步利用每个突变。因此,物种过少对进化也是有害的。在极性*衡实验结束时,你获得了一些与通过调整NEAT算法(如兼容性阈值)的相应超参数来保持物种数量*衡相关的实际技能。

在极性*衡实验中强调的神经进化过程的另一个重要特征与指导进化过程的随机过程的正确初始条件的选择有关。神经进化方法的实现建立在伪随机数生成器的基础上,该生成器提供了基因组突变和交叉率的概率。在伪随机数生成器中,将要生成的数字序列完全由在开始时提供给生成器的初始种子值决定。通过使用相同的种子值,可以使用伪随机数生成器产生相同的随机数序列。

通过对摆杆*衡器的进化控制器的ANN进行实验,我们发现找到成功解决方案的概率强烈依赖于随机数生成器种子值。

掌握杆*衡实验使你准备好解决与自主导航相关的更复杂问题,这些问题将在下一章中讨论。

自主迷宫导航

在本章中,我们继续进行神经进化的实验,试图创建一个能够从迷宫中找到出口的求解器。迷宫求解是一个有趣的问题,因为它允许我们研究一种新的搜索优化方法,称为新颖性搜索。在第5章《自主迷宫导航》和第6章《新颖性搜索优化方法》中,我们探索了一系列使用以目标为导向的搜索优化和新颖性搜索优化方法的迷宫导航实验。

在本章中,你学习了如何实现一个具有传感器阵列的机器人模拟,这些传感器可以检测障碍物并监控其在迷宫中的位置。我们还讨论了如何实现以目标为导向的目标函数来引导进化过程。所提到的目标函数实现是计算机器人最终位置与迷宫出口之间的欧几里得距离。

使用迷宫导航模拟器和定义的目标函数,我们对简单和困难的迷宫配置进行了两次实验。实验结果让我们了解了欺骗性适应度函数景观对进化过程性能的影响。在局部最优区域,神经进化倾向于产生更少的物种,这阻碍了其探索新解决方案的能力。在极端情况下,这可能导致进化过程的退化。这可能导致整个种群中只有一个物种。

同时,你学习了如何通过调整NEAT超参数,如兼容性不连接系数来避免此类不幸。此参数控制了比较基因组中拓扑差异的强度如何影响兼容性因子,该因子用于确定基因组是否属于同一物种。因此,我们能够提高物种形成并增加种群多样性。这种变化对寻找成功的迷宫求解器产生了积极影响,我们能够为简单的迷宫配置找到它。然而,具有更多极端局部最优区域困难的迷宫配置抵抗了我们使用以目标为导向的目标函数寻找成功的迷宫求解器的所有尝试。

因此,我们准备了解新颖性搜索优化方法,该方法旨在克服以目标为导向的搜索的限制。

新颖性搜索优化方法

在本章之前的所有实验中,我们将目标函数定义为基于其接*问题最终目标导数的函数。然而,迷宫求解问题提出了无法通过以目标为导向的目标函数解决的问题。特定的迷宫配置可以引入强烈的局部最优解,其中以目标为导向的目标搜索可能会陷入困境。在许多情况下,这种欺骗性的适应度函数景观有效地阻止了以目标为导向的目标搜索找到成功的解决方案。

因此,利用我们在上一章创建迷宫求解器过程中获得的实际经验,我们开始了创建更高级求解器的道路。我们全新的求解器使用了新颖度搜索优化方法来指导进化过程。然而,首先我们需要定义适当的指标来估计每一代中每个解决方案的新颖度评分。这个指标产生的新颖度评分将被用作分配给求解器种群中基因组的适应度值。因此,新颖度被整合到标准的神经进化过程中。

新颖度指标应该衡量每个解决方案相对于我们过去找到的解决方案以及当前生成中的所有解决方案的新颖程度。有两种方法来衡量解决方案的新颖性:

  • 基因型新颖度是新颖度评分,显示了当前解决方案的基因型与所有其他找到的解决方案的基因型的差异。

  • 行为新颖度展示了当前解决方案在问题空间中的行为与所有其他解决方案相比的差异。

对于解决迷宫的问题,一个好的选择是使用行为新颖度评分,因为最终我们感兴趣的是到达迷宫出口,这可以通过展示某种行为来促进。此外,行为新颖度评分比基因型新颖度评分更容易计算。

某个求解器在迷宫中的轨迹定义了其行为空间。因此,我们可以通过比较求解器的轨迹向量来估计新颖度评分。从数值上讲,新颖度评分可以通过计算轨迹向量之间的欧几里得距离来估计。为了进一步简化这个任务,我们可以只使用求解器轨迹的最后一个点的坐标来估计新颖度评分。

在定义了新颖度指标之后,你学习了如何使用Python在源代码中实现它,并将其集成到你在第五章,“自主迷宫导航”中创建的迷宫模拟器中。之后,你就可以准备重复上一章的实验并比较结果。

简单迷宫求解器的实验展示了产生的控制ANN拓扑结构得到了改进。拓扑结构变得最优且更简单。

不幸的是,与硬迷宫配置的实验也未能产生成功的求解器,就像在第5章自主迷宫导航中做的那样。失败似乎是由于实验中使用的NEAT算法特定实现的低效性造成的。我已经用Go语言实现了NEAT算法,使其能够轻松地使用新颖性搜索优化解决硬迷宫配置。你可以在GitHub上找到它:https://github.com/yaricom/goNEAT_NS

第6章,“新颖性搜索优化方法”中,你了解到新颖性搜索优化方法允许你在适应度函数具有欺骗性景观且内部散布着许多局部最优陷阱的情况下找到解决方案。你已经了解到通往解决方案的垫脚石并不总是显而易见的。有时,你需要退一步找到正确的方法。这正是新颖性搜索方法背后的主要思想。它试图通过完全忽略最终目标附*的距离,并奖励在寻找过程中找到的每个中间解决方案的新颖性来找到解决方案。

在本章中,我们熟悉了标准的NEAT算法,并准备好开始实验其更高级的扩展。

基于超立方体的NEAT视觉识别

本章是我们讨论高级神经进化方法的四章中的第一章。在本章中,你学习了间接基因组编码方案,该方案使用组合模式生成网络CPPN)来帮助编码大型表型ANN拓扑结构。NEAT扩展引入的CPPN编码方案被称为HyperNEAT。这个扩展围绕着表示表型ANN拓扑结构的连接基质的概念构建。同时,基质中节点之间的连接被表示为超立方体内的四维点。在HyperNEAT方法中,CPPN的拓扑结构是进化的部分,并由NEAT算法指导。我们已经讨论了HyperNEAT的细节,因此为了简洁起见,我们跳过了HyperNEAT的其余细节。

在本章中,我们向你提出了一个有趣的视觉识别任务,这突出了HyperNEAT算法在视觉场中区分模式的能力。你了解到HyperNEAT方法能够找到成功的视觉模式识别器,因为它固有的能力在编码求解器表型ANN的基质中多次重用成功的连接模式。这是由于CPPN的力量,它可以通过从输入节点(感知图像)传递信号到输出节点(表示结果)来发现正确的策略。

你学习了如何选择合适的底物几何形状,以有效地利用CPPN的能力来找到几何规律。之后,你有机会通过实现使用HyperNEAT算法训练的视觉判别器来将你获得的知识应用于实践。

此外,完成视觉判别器实验后,你能够验证间接编码方案的有效性。我们通过比较生成的CPPN拓扑结构与判别器ANN底物中可能的最大连接数来进行比较。视觉判别器实验的结果相当令人印象深刻。我们通过编码底物中14,641个可能的连接中的连接模式,仅使用CPPN中10个节点之间的16个连接,实现了0.11%的信息压缩比。

由于输入信号的高维性,视觉任务对判别器ANN架构提出了很高的要求。因此,在第8章“ES-HyperNEAT与视网膜问题”中,我们继续回顾了另一类视觉识别问题。

ES-HyperNEAT与视网膜问题

在本章中,你学习了如何选择最适合特定问题空间的底物配置。然而,并不总是明显应该选择哪种配置。如果你选择了错误的配置,可能会显著影响训练过程的表现。因此,神经进化过程可能无法产生成功的解决方案。此外,特定的底物配置细节只能在训练过程中发现,不能提前知道。

使用ES-HyperNEAT方法解决了寻找合适的底物配置的问题。在本章中,你学习了神经进化过程如何自动处理底物配置在连接性CPPN进化过程中的进化。我们向你介绍了四叉树数据结构的概念,它允许有效地遍历底物拓扑结构并检测信息密度高的区域。我们了解到,将这些区域自动放置新节点以创建更微妙的连接模式是有益的,这些模式描述了在现实世界中可以发现的隐藏规律。

在熟悉了ES-HyperNEAT算法的细节之后,你学习了如何将其应用于解决被称为视网膜问题的视觉识别任务。在这个任务中,神经进化过程需要发现一种求解器,能够在两个独立的视觉场中同时识别有效模式。也就是说,检测器ANN必须决定右眼和左眼视觉场中呈现的模式是否对每个场是有效的。通过引入模块化架构到检测器ANN的拓扑结构中,可以找到这个任务的解决方案。在这种配置下,每个ANN模块只负责视网膜相关侧的图案识别。

在本章中,我们使用ES-HyperNEAT方法实现了一个成功的视网膜问题求解器。我们能够通过视觉确认,所生成的检测器ANN拓扑结构中包含了模块化结构。此外,从实验结果中,你了解到所得到的检测器ANN结构具有*似最优的复杂性。再次证明,基于神经进化的方法通过逐步复杂化的方式发现有效解决方案的潜力。

所有的实验,包括本章中描述的实验,都使用了一种在实验开始之前预先定义的特定形式的适应度函数。然而,探索如果允许适应度函数与其试图优化的解决方案协同进化,神经进化算法的性能将如何变化,将会很有趣。

协同进化与SAFE方法

在本章中,我们讨论了协同进化策略在自然界中的广泛存在,以及它可以转移到神经进化领域。你了解了自然界中最常见的协同进化策略:互利共生、竞争(捕食或寄生)和共栖。在我们的实验中,我们探索了共栖类型的进化,这种进化可以在共栖关系中定义为:一种物种的成员在没有造成伤害或给予其他参与物种利益的情况下获得利益。

在了解了自然界中的进化策略之后,你准备好理解SAFE方法背后的概念。SAFE的缩写意味着解决方案与适应度进化,这表明我们有两个协同进化的种群:潜在解决方案的种群和适应度函数候选者的种群。在进化的每一代中,我们评估每个潜在解决方案与所有目标函数候选者,并选择最佳的适应度分数,这被视为编码解决方案的基因组的适应度。同时,我们使用新颖性搜索方法进化适应度函数候选者的共栖种群。新颖性搜索使用种群中每个基因组的基因组新颖性作为新颖性指标来估计个体的适应度分数。

在本章中,你学习了如何基于SAFE方法实现一个修改后的迷宫求解实验,以评估协同进化策略的性能。你还学习了如何定义目标函数来指导潜在解集的进化。这个目标函数包括两个适应度指标:第一个是从迷宫出口的距离,而第二个是找到的解决方案的行为新颖性。这些指标通过由适应度函数候选者群体产生的系数进行组合。

正如所有前面的章节一样,你通过使用MultiNEAT Python库实现SAFE方法来继续提高你的Python技能。在下一章中,你将继续研究更高级的方法,从而允许你使用神经进化来训练Atari游戏求解器。

深度神经进化

在本章中,我们向你介绍了深度神经进化的概念,它可以用来训练深度人工神经网络DNNs)。你学习了如何使用深度神经进化通过深度强化学习算法来训练Atari游戏代理。

我们首先讨论了强化学习背后的基本概念。我们特别关注了流行的Q学习算法,这是强化学习的经典实现之一。之后,你学习了如何使用深度神经网络(DNN)来*似复杂任务的Q值函数,这些任务无法通过简单的动作-状态表和Q值来*似。接下来,我们讨论了如何使用基于神经进化的方法来找到DNN的可训练参数。你了解到神经进化通过进化DNN来*似Q值函数。因此,我们可以训练适当的DNN,而无需使用传统DNN训练方法中常见的任何形式的错误反向传播。

在了解了深度强化学习之后,你准备通过实现Atari游戏求解代理来将你的知识应用于实践。为了训练一个能够玩Atari游戏的代理,它需要读取游戏屏幕的像素并推导出当前的游戏状态。之后,使用提取的游戏状态,代理需要选择在游戏环境中执行的正确动作。代理的最终目标是最大化在完成特定游戏剧集后收到的最终奖励。因此,我们有了经典的试错学习,这是强化学习的本质。

正如我们提到的,游戏代理需要解析游戏屏幕像素。最好的方式是使用卷积神经网络CNN)来处理从游戏屏幕接收到的输入。在本章中,我们讨论了CNN架构的基本要素以及它如何集成到游戏代理中。你学习了如何使用流行的TensorFlow框架在Python中实现CNN。

此外,你还了解了一种独特的基因组编码方案,该方案专门为与深度神经进化相关的任务而设计。这个方案使我们能够编码具有数百万可训练参数的表型人工神经网络(ANN)。所提出的方案使用伪随机数生成器的种子来编码表型ANN的连接权重。在这个编码方案中,基因组被表示为随机生成器种子的列表。每个种子依次用于从伪随机数源生成所有连接权重。

在了解了基因组编码的细节之后,你准备开始一个旨在创建能够玩Frostbite Atari游戏的智能体的实验。此外,你还学习了如何使用现代GPU加速训练过程中的计算。在本章的最后,我们还介绍了一个高级可视化工具(VINE),它允许我们研究神经进化实验的结果。

通过本章,我们结束了与当时写作本书时最流行的神经进化方法的简要接触。然而,在快速发展的应用人工智能和神经进化方法领域,还有很多东西可以学习。

接下来该去哪里

我们希望你在本书中介绍的神经进化方法之旅既愉快又有见地。我们尽最大努力向您展示了神经进化领域的最新成就。然而,这个应用计算机科学领域正在快速发展,几乎每个月都有新的成就公布。全球有众多大学和公司的研究实验室正在致力于将神经进化方法应用于解决主流深度学习算法力所不能及的任务。

我们希望你对我们在讨论中提到的神经进化方法产生了浓厚的兴趣,并渴望将它们应用于你的工作和实验中。然而,你需要继续自我教育,以跟上该领域未来成就的步伐。在本节中,我们将介绍一些你可以继续学习的地方。

Uber AI Labs

Uber AI Labs的核心建立在由神经进化领域的杰出先驱之一Kenneth O. Stanley共同创立的几何智能初创公司周围。他是NEAT算法的作者,我们在本书中经常使用这个算法。你可以通过https://eng.uber.com/category/articles/ai/跟踪Uber AI Labs的工作。

alife.org

国际人工生命学会ISAL)是一个由世界各地对与人工生命相关的科学研究活动感兴趣的研究人员和爱好者组成的成熟社区。特别是遗传算法和神经进化是该学会感兴趣的研究领域之一。ISAL出版《人工生命》期刊并赞助各种会议。您可以在 http://alife.org 了解更多关于ISAL的活动。

Reddit上的开放式进化

开放式进化的概念与遗传算法和神经进化特别是直接相关。开放式进化假设创建一个不受任何特定目标限制的进化过程。它受到自然生物进化(产生了我们人类)的启发。有一个专门的subreddit,所有对此感兴趣的人都在那里讨论研究。您可以在 https://www.reddit.com/r/oee/ 找到它。

NEAT软件目录

中佛罗里达大学维护了一个实现NEAT算法及其扩展的软件库列表。该软件由NEAT算法的作者Kenneth O. Stanley监管。我在Go语言中实现的NEAT和新颖性搜索也包含在这个目录中。您可以在 http://eplex.cs.ucf.edu/neat_software/ 找到它。

arXiv.org

arXiv.org 是一个广为人知的提供许多科学领域论文预印本的服务。它通常是计算机科学领域前沿信息的极好来源。您可以使用以下搜索查询在 http://search.arxiv.org:8081/?query=neuroevolution&in=grp_cs 中搜索与神经进化相关的论文。

NEAT算法论文

由Kenneth O. Stanley撰写的描述NEAT算法的原始论文是一篇非常启发性的阅读材料,并推荐给所有对神经进化感兴趣的人。它可在 http://nn.cs.utexas.edu/downloads/papers/stanley.phd04.pdf 找到。

摘要

在本章中,我们简要总结了本书中学到的内容。您还了解了可以进一步寻找洞察力和继续自我教育的地方。

我们很高兴生活在一个时代,未来以如此快的速度成为现实,以至于我们完全未能注意到在我们生活中发生的巨大变化。人类正在迅速走上掌握基因编辑和合成生物学的奇迹之路。我们继续征服人类大脑的深层奥秘,这为我们最终理解我们的意识开辟了道路。我们在宇宙学方面的先进实验使我们能够越来越接*宇宙的最初时刻。

我们构建了一项先进的数学工具,使我们能够描述诸如中微子这样的神秘现象,它在路径上可以变成一个电子,然后又变回中微子。我们的技术成就与魔法难以区分,正如亚瑟·C·克拉克所说。

生活在于感受它的美。保持你的思维敏锐,始终保持好奇心。我们站在合成意识研究的火花即将点燃新型生命形式进化的边缘。而且谁知道呢——也许你就是那个开始这一进程的人。

感谢,我亲爱的读者,感谢你花费的时间和精力。我期待着看到你将如何利用从这本书中获得的知识创造出什么。

posted @ 2025-09-20 21:35  绝不原创的飞龙  阅读(44)  评论(0)    收藏  举报