Java8-遗传算法基础-一-

42 阅读1小时+

Java8 遗传算法基础(一)

协议:CC BY-NC-SA 4.0

一、简介

Electronic supplementary material The online version of this chapter (doi:10.​1007/​978-1-4842-0328-6_​1) contains supplementary material, which is available to authorized users.

数字计算机和信息时代的兴起彻底改变了现代生活方式。数字计算机的发明使我们能够将生活的许多领域数字化。这种数字化使我们能够将许多繁琐的日常工作外包给计算机,而以前可能需要人类来完成。这方面的一个日常例子是现代文字处理应用,它具有内置的拼写检查功能,可以自动检查文档中的拼写和语法错误。

随着计算机发展得越来越快,计算能力越来越强,我们已经能够使用它们来完成越来越复杂的任务,例如理解人类的语言,甚至在一定程度上准确地预测天气。这种不断的创新使我们能够将越来越多的任务外包给计算机。今天的计算机可能每秒钟能够执行数十亿次运算,但是无论它们在技术上变得多么有能力,除非它们能够学习和适应更好地适应呈现给它们的问题,否则它们将永远受限于我们人类为它们编写的任何规则或代码。

人工智能领域和遗传算法子集正开始解决当今数字世界面临的一些更复杂的问题。通过将遗传算法应用到现实世界的应用中,有可能解决用更传统的计算方法几乎不可能解决的问题。

什么是人工智能?

1950 年,艾伦·图灵——一位数学家和早期的计算机科学家——写了一篇名为《计算机械和智能》的著名论文,他在文中质疑道:“计算机能思考吗?”他的问题引起了很多关于什么是真正的智能以及计算机的基本限制是什么的争论。

许多早期的计算机科学家认为,计算机不仅能够展示类似智能的行为,而且在短短几十年的研究中,它们将达到人类的智能水平。这一观点是由司马贺在 1965 年提出的,当时他宣称,“在 20 年内,机器将能够做任何人能做的工作。”当然,现在,50 多年过去了,我们知道西蒙的预测与现实相去甚远,但当时许多计算机科学家同意他的观点,并把创造“强人工智能”机器作为他们的目标。一台强大的人工智能机器仅仅是一台至少和人类一样有能力完成任何任务的机器。

今天,自艾伦·图灵的著名问题提出 50 多年后,机器是否最终能够以类似于人类的方式思考的可能性在很大程度上仍然没有答案。直到今天,他关于“思考”的意义的论文和思想仍然受到哲学家和计算机科学家的广泛争论。

尽管我们还远未创造出能够复制人类智能的机器,但在过去几十年里,我们无疑已经在人工智能方面取得了重大进展。自 20 世纪 50 年代以来,对“强人工智能”和开发可与人类相媲美的人工智能的关注开始转向支持“弱人工智能”。弱人工智能是开发更狭隘的智能机器,这在短期内更容易实现。这种更狭隘的关注让计算机科学家能够创造出实用且看似智能的系统,比如苹果的 Siri 和谷歌的无人驾驶汽车。

当创建一个弱人工智能系统时,研究人员通常会专注于构建一个系统或机器,它只是“智能”到需要完成一个相对较小的问题。这意味着我们可以应用更简单的算法,使用更少的计算能力,同时仍能获得结果。相比之下,强大的人工智能研究侧重于建造一台足够智能的机器,能够解决我们人类可以解决的任何问题。由于问题的范围,这使得使用强 AI 构建最终产品变得不太实际。

在短短几十年里,弱人工智能系统已经成为我们现代生活方式的一个常见组成部分。从下棋,到帮助人类驾驶战斗机,弱人工智能系统已经证明自己在解决曾经认为只有人类才能解决的问题方面是有用的。随着数字计算机变得越来越小,计算能力越来越强,这些系统的有用性可能只会随着时间的推移而增加。

生物类似物

当早期的计算机科学家第一次试图建立人工智能系统时,他们经常从大自然中寻找灵感,了解他们的算法如何工作。通过创建模拟自然界中发现的过程的模型,计算机科学家能够赋予他们的算法进化的能力,甚至复制人脑的特征。正是实现了他们受生物启发的算法,使得这些早期的先驱首次赋予他们的机器适应、学习和控制环境的能力。

通过使用不同的生物类比作为开发人工智能系统的指导性隐喻,计算机科学家创造了不同的研究领域。自然,启发了每个研究领域的不同生物系统都有它们自己特定的优势和应用。一个成功的领域,也是我们在本书中关注的领域,是进化计算——其中遗传算法构成了大部分研究。其他领域集中在稍微不同的领域,例如模拟人类大脑。这个研究领域被称为人工神经网络,它使用生物神经系统的模型来模仿其学习和数据处理能力。

进化计算的历史

进化计算最初是在 20 世纪 50 年代作为一种优化工具进行探索的,当时计算机科学家正在尝试将达尔文的生物进化思想应用于一群候选解。他们从理论上推断,也许可以应用进化算子,如交叉(类似于生物繁殖)和突变(将新的遗传信息添加到基因组中的过程)。正是这些操作符加上选择压力,使得遗传算法有能力在一段时间后“进化”出新的解决方案。

在 20 世纪 60 年代,Rechenberg (1965,1973)首先提出了“进化策略”——一种应用自然选择和进化思想的优化技术,他的思想后来被 Schwefel (1975,1977)扩展。当时,其他计算机科学家也在类似的研究领域独立工作,如 Fogel L . J;欧文斯,A . J;和 Walsh,M . J .(1966),他们是第一个引入进化程序设计领域的人。他们的技术包括将候选解表示为有限状态机,并应用变异来创建新的解。

在 20 世纪 50 年代和 60 年代,一些研究进化的生物学家开始尝试用计算机模拟进化。然而,是 Holland,J.H. (1975)在 20 世纪 60 年代和 70 年代首先发明和发展了遗传算法的概念。1975 年,他终于在他的开创性著作《自然和人工系统中的适应》中提出了他的想法。霍兰德的书展示了达尔文进化论是如何通过计算机抽象和建模用于优化策略的。他的书解释了如何将生物染色体建模为 1 和 0 的字符串,以及如何通过实施自然选择中的技术(如突变、选择和交叉)来“进化”这些染色体的种群。

自 20 世纪 70 年代首次引入以来,几十年来,Holland 对遗传算法的最初定义逐渐发生了变化。这在某种程度上是因为最近在进化计算领域工作的研究人员偶尔会将不同方法的想法结合在一起。虽然这模糊了许多方法之间的界限,但它为我们提供了丰富的工具集,可以帮助我们更好地解决具体问题。本书中的术语“遗传算法”将被用来指霍兰德关于遗传算法的经典观点,以及更广泛的、现今的对这些词的解释。

直到今天,计算机科学家仍在研究生物学和生物系统,以便为他们提供创建更好算法的思路。最近受到生物学启发的优化算法之一是蚁群优化算法,它是由 Marco,D. (1992)于 1992 年首次提出的。蚂蚁群体优化将蚂蚁的行为建模为解决各种优化问题(如旅行商问题)的方法。

进化计算的优势

智能机器在我们社会中被采用的速度本身就是对它们有用性的认可。我们用计算机解决的绝大多数问题都可以归结为相对简单的静态决策问题。随着可能的输入和输出数量的增加,这些问题会迅速变得更加复杂,并且当解决方案需要适应不断变化的问题时,只会变得更加复杂。除此之外,一些问题可能还需要算法来搜索大量可能的解决方案,以试图找到可行的解决方案。根据需要搜索的解决方案的数量,经典的计算方法可能无法在可用的时间框架内找到可行的解决方案——即使使用超级计算机。正是在这种情况下,进化计算可以伸出援手。

为了给你一个我们可以用经典计算方法解决的典型问题的概念,考虑一个交通灯系统。交通灯是相对简单的系统,只需要基本的智能操作。交通灯系统通常只有几个输入,可以提醒它事件,如等待使用路口的汽车或行人。然后,它需要管理这些输入,并正确地改变信号灯,使汽车和行人能够有效地使用路口,而不会造成任何事故。尽管操作交通灯系统可能需要一定量的知识,但是它的输入和输出是足够基本的,以至于一组操作交通灯系统的指令可以由人类设计和编程而没有太大问题。

我们经常需要一个智能系统来处理更复杂的输入和输出。这可能意味着对人类来说,编写一组指令使机器能够正确地将输入映射到可行的输出不再简单,或者可能是不可能的。在这些情况下,问题的复杂性使得人类程序员无法用代码解决问题,优化和学习算法可以为我们提供一种方法,使用计算机的处理能力来找到问题本身的解决方案。这方面的一个例子可能是构建一个可以根据交易信息识别欺诈交易的欺诈检测系统。虽然交易数据和欺诈交易之间可能存在某种关系,但它可能取决于数据本身的许多细微之处。正是这些输入中的微妙模式可能很难被人类编码,这使得它成为应用进化计算的一个很好的候选。

当人类不知道如何解决问题时,进化算法也很有用。这方面的一个经典例子是,美国国家航空航天局(NASA)正在寻找一种能够满足 2006 年太空任务所有要求的天线设计。美国宇航局编写了一种遗传算法,该算法使天线设计符合所有特定的设计约束,如信号质量、尺寸、重量和成本。在这个例子中,NASA 不知道如何设计一个能满足他们所有要求的天线,所以他们决定写一个能进化出天线的程序。

我们可能想要应用进化计算策略的另一种情况是当问题不断变化,需要一个适应性的解决方案时。在构建算法对股市进行预测时,可以发现这个问题。在一周内对股票市场做出准确预测的算法可能在下一周内也不会做出准确预测。这是因为股票市场的模式和趋势永远在变化,因此预测算法非常不可靠,除非它们能够快速适应不断变化的模式。进化计算可以通过提供一种根据需要对预测算法进行调整的方法来帮助适应这些变化。

最后,有些问题需要在大量的,或者可能是无限量的潜在解决方案中进行搜索,以找到所面临问题的最佳或足够好的解决方案。从根本上说,所有的进化算法都可以被看作是搜索算法,它在一组可能的解决方案中搜索,寻找最好的或者“最合适的”解决方案。如果你把在一个有机体的基因组中发现的所有潜在的基因组合都看作是候选的解决方案,你也许能想象出这一点。生物进化擅长通过搜索这些可能的基因序列来找到一个充分适合其环境的解决方案。在更大的搜索空间中,即使使用进化算法,也可能找不到给定问题的最佳解决方案。然而,对于大多数优化问题来说,这很少是一个问题,因为通常我们只需要一个足够好的解决方案来完成工作。

进化计算提供的方法可以被认为是一种“自底向上”的范例。当算法中出现的所有复杂性都来自简单的、潜在的规则时。另一种方法是“自上而下”的方法,这种方法要求所有算法中的复杂性都由人类来编写。遗传算法开发起来相当简单;这使得它们在需要复杂算法来解决问题时成为一个有吸引力的选择。

下面是一个特征列表,这些特征可以使问题成为进化算法的一个很好的候选:

  • 如果问题很难编写代码来解决
  • 当一个人不确定如何解决问题时
  • 如果问题是不断变化的
  • 当搜索每个可能的解决方案不可行时
  • 当“足够好”的解决方案可以接受时

生物进化

生物进化,通过自然选择的过程,最早是由查尔斯·达尔文(1859 年)在他的著作《物种起源》中提出的。正是他的生物进化概念启发了早期的计算机科学家去适应和使用生物进化作为他们的优化技术的模型,这可以在进化计算算法中找到。

因为遗传算法中使用的许多想法和概念直接源于生物进化,所以对该主题的基本熟悉有助于更深入地理解该领域。也就是说,在我们开始探索遗传算法之前,让我们先浏览一下生物进化的基础知识(有些简化)。

所有生物体都含有 DNA,它编码了构成生物体的所有不同特征。DNA 可以被认为是生命从零开始创造有机体的说明书。改变生物体的 DNA 会改变其特征,如眼睛和头发的颜色。DNA 由单个基因组成,正是这些基因负责编码生物体的特定特征。

一个有机体的基因聚集在染色体中,一套完整的染色体构成了一个有机体的基因组。所有生物都至少有一条染色体,但通常包含更多,例如人类有 46 条染色体,有些物种有超过 1000 条!在遗传算法中,我们通常将染色体称为候选解。这是因为遗传算法通常使用单个染色体来编码候选解。

特定性状的各种可能的设置被称为“等位基因”,该性状在染色体上编码的位置被称为“位点”。我们将特定的基因组称为“基因型”,基因型编码的物理有机体称为“表型”。

当两个生物体交配时,来自两个生物体的 DNA 被带到一起并以这样的方式结合,从而产生的生物体——通常被称为后代——从其第一个父母那里获得 50%的 DNA,另 50%从第二个父母那里获得。生物体 DNA 中的一个基因偶尔会发生突变,为它提供双亲都没有的 DNA。这些突变通过向种群中添加先前无法获得的基因,为种群提供了遗传多样性。群体中所有可能的遗传信息被称为群体的“基因库”。

如果产生的有机体足够适合在它的环境中生存,它可能会自我交配,允许它的 DNA 延续到未来的种群中。然而,如果产生的生物体不适合生存并最终交配,其遗传物质将不会传播到未来的种群中。这就是为什么进化偶尔被称为适者生存——只有最适者才能生存并传递他们的 DNA。正是这种选择性压力慢慢引导进化去寻找越来越适合和更好适应的个体。

生物进化的一个例子

为了帮助阐明这个过程将如何逐渐导致越来越健康的个体的进化,考虑下面的例子:

在一个遥远的星球上,存在着一种形状为白色正方形的物种。

A978-1-4842-0328-6_1_Figa_HTML.jpg

白色的方形物种已经和平地生活了几千年,直到最近一个新的物种到来,黑色的圆形。

A978-1-4842-0328-6_1_Figb_HTML.jpg

黑圈物种是食肉动物,开始以白方种群为食。

A978-1-4842-0328-6_1_Figc_HTML.jpg

白色方块没有任何方法来保护自己免受黑色圆圈的攻击。直到有一天,其中一个幸存的白方随机从一个白方突变成了一个黑方。黑色圆圈不再把新的黑色方块视为食物,因为它和自己是同一个颜色。

A978-1-4842-0328-6_1_Figd_HTML.jpg

一些幸存的广场人口交配,创造了新一代的广场。其中一些新方块继承了黑色方块的颜色基因。

A978-1-4842-0328-6_1_Fige_HTML.jpg

然而,白色方块继续被吃掉…

A978-1-4842-0328-6_1_Figf_HTML.jpg

最终,由于它们看起来与黑圈相似的进化优势,它们不再被吃掉。现在,正方形剩下的唯一颜色是黑色正方形。

A978-1-4842-0328-6_1_Figg_HTML.jpg

不再受黑色圆圈的控制,黑色方块再次自由地生活在和平之中。

A978-1-4842-0328-6_1_Figh_HTML.jpg

基本术语

遗传算法建立在生物进化的概念上,所以如果你熟悉进化中的术语,你可能会注意到在使用遗传算法时术语的重叠。这些领域之间的相似性当然是由于进化算法,更具体地说,遗传算法类似于自然界中发现的过程。

条款

重要的是,在我们深入到遗传算法领域之前,我们首先要理解一些使用的基本语言和术语。随着本书的进展,将根据需要引入更复杂的术语。下面列出了一些比较常见的术语,以供参考。

  • 群体——这只是一个候选解决方案的集合,可以应用遗传操作符,如突变和交叉。
  • 候选解决方案–给定问题的可能解决方案。
  • 基因——构成染色体的不可分割的构件。传统上,一个基因由 0 或 1 组成。
  • 染色体——染色体是一串基因。染色体定义了一个特定的候选解。具有二进制编码的典型染色体可能包含类似“01101011”的内容。
  • 突变——候选解决方案中的基因被随机改变以创造新性状的过程。
  • 交叉——染色体结合产生新的候选解的过程。这有时被称为重组。
  • 选择——这是挑选候选解决方案以培育下一代解决方案的技术。
  • 适合度–衡量候选解决方案适合给定问题的程度的分数。

搜索空间

在计算机科学中,当处理具有许多需要搜索的候选解的优化问题时,我们将解的集合称为“搜索空间”。搜索空间中的每个特定点都充当给定问题的候选解决方案。在这个搜索空间中,有一个距离的概念,距离较近的解决方案比距离较远的解决方案更有可能表达相似的特征。为了理解这些距离在搜索空间上是如何组织的,考虑下面使用二进制遗传表示的例子:

“101”和“111”只差 1 个。这是因为从“101”转换到“111”只需要 1 次改变(将 0 翻转到 1)。这意味着这些解决方案在搜索空间上仅相隔 1 个空间。

另一方面,“000”与“111”相差三个数量级。这使得它的距离为 3,将“000”放置在搜索空间中距离“111”3 个空格的位置。

因为具有较少变化的解决方案被分组为彼此更接近,所以搜索空间上的解决方案之间的距离可以用于提供另一个解决方案所具有的特性的近似。这种理解经常被许多搜索算法用作改善其搜索结果的策略。

健身景观

当在搜索空间内找到的候选解被标记为它们各自的适合度水平时,我们可以开始把搜索空间看作一个“适合度景观”。图 1-1 提供了一个 2D 健身景观的例子。

A978-1-4842-0328-6_1_Fig1_HTML.jpg

图 1-1。

A 2D fitness landscape

在我们的适应度图的底部轴上是我们正在优化的值,在左侧轴上是其相应的适应度值。我应该注意到,这通常是对实践中发现的问题的过度简化。大多数真实世界的应用有多个值,需要优化创建一个多维健身景观。

在上面的例子中,可以看到搜索空间中每个候选解的适应值。这使得很容易看到最适合的解决方案位于何处,然而,为了使这在现实中成为可能,搜索空间中的每个候选解决方案都需要对它们的适合度函数进行评估。对于具有指数搜索空间的复杂问题,评估每个解的适应值是不合理的。在这些情况下,搜索算法的工作是找到最佳解决方案可能驻留的位置,同时被限制为只能看到一小部分搜索空间。图 1-2 是一个搜索算法通常会看到的例子。

A978-1-4842-0328-6_1_Fig2_HTML.jpg

图 1-2。

A more typical search fitness space

考虑一种算法,该算法在十亿(1,000,000,000)个可能的解决方案的搜索空间中进行搜索。即使每个解决方案只需要 1 秒钟来评估并分配一个适应值,也仍然需要 30 多年来明确搜索每个潜在的解决方案!如果我们不知道搜索空间中每个解决方案的适合度值,那么我们就无法确切地知道最佳解决方案位于何处。在这种情况下,唯一合理的方法是使用能够在可用的时间框架内找到足够好的解决方案的搜索算法。在这种情况下,遗传算法和进化算法在相对较短的时间内找到可行的、接近最优的解决方案是非常有效的。

遗传算法在搜索搜索空间时使用群体方法。作为其搜索策略的一部分,遗传算法将假设两个排序较好的解决方案可以组合起来,以形成更合适的后代。这个过程可以在我们的健身景观上可视化(图 1-3 )。

A978-1-4842-0328-6_1_Fig3_HTML.jpg

图 1-3。

Parent and offspring in the fitness plot

遗传算法中的变异算子允许我们搜索特定候选解的近邻。当突变应用于一个基因时,它的值是随机变化的。这可以通过在搜索空间上单步执行来描绘(图 1-4 )。

A978-1-4842-0328-6_1_Fig4_HTML.jpg

图 1-4。

A fitness plot showing the mutation

在交叉和变异的例子中,有可能得到比我们最初设定的更不合适的解决方案(图 1-5 )。

A978-1-4842-0328-6_1_Fig5_HTML.jpg

图 1-5。

A poor fitness solution

在这种情况下,如果解决方案表现不佳,最终将在选择过程中从基因库中删除。只要群体的平均趋势倾向于更合适的解决方案,单个候选解决方案中的小的负变化是好的。

局部最优

当实现优化算法时,应该考虑的一个障碍是该算法在搜索空间中能多好地脱离局部最优位置。为了更好地理解什么是局部最优,请参考图 1-6 。

A978-1-4842-0328-6_1_Fig6_HTML.jpg

图 1-6。

A local optimum can be deceiving

在这里,我们可以看到健身景观上的两座山峰,它们的高度略有不同。如前所述,优化算法无法看到整个适应度,相反,它能做的最好的事情是找到它认为可能在搜索空间中处于最佳位置的解决方案。正是由于这一特性,优化算法常常会不知不觉地将其搜索集中在搜索空间的次优部分。

当实现一个简单的爬山算法来解决任何足够复杂的问题时,这个问题很快变得显而易见。一个简单的爬山者没有任何固有的方法来处理局部最优,结果常常会在搜索空间的局部最优区域终止搜索。一个简单的随机爬山器相当于一个没有种群和交叉的遗传算法。该算法相当容易理解,它从搜索空间中的一个随机点开始,然后通过评估它的邻近解来试图找到一个更好的解。当爬山者在它的邻居中找到一个更好的解决方案时,它将移动到新的位置并重新开始搜索过程。这一过程将通过逐步爬上它在搜索空间中找到的任何一座山来逐渐找到改进的解决方案——因此得名“爬山者”。当爬山者再也找不到更好的解决方案时,它会认为自己在山顶,并停止搜索。

图 1-7 展示了爬山算法的典型运行情况。

A978-1-4842-0328-6_1_Fig7_HTML.jpg

图 1-7。

Shows how the hill climber works

上图展示了一个简单的爬山算法如何在搜索空间的一个局部最优区域开始搜索时轻松返回一个局部最优解。

虽然在没有首先评估整个搜索区域的情况下,没有任何保证的方法来避免局部最优,但是有许多算法的变体可以帮助避免局部最优。一种最基本、最有效的方法叫做随机重启爬山法,它简单地从随机的起始位置多次运行爬山算法,然后返回从各次运行中找到的最佳解决方案。这种优化方法相对容易实现,而且效果惊人。其他方法,如模拟退火(见 Kirkpatrick、Gelatt 和 Vecchi (1983))和禁忌搜索(见 Glover (1989)和 Glover (1990))是爬山算法的微小变化,它们都具有有助于减少局部最优解的特性。

遗传算法在避免局部最优和检索接近最优的解方面惊人地有效。实现这一点的方法之一是通过使种群能够对搜索空间的大区域进行采样,从而定位继续搜索的最佳区域。图 1-8 显示了初始化时人口的分布情况。

A978-1-4842-0328-6_1_Fig8_HTML.jpg

图 1-8。

Sample areas at initialization

在几代人过去之后,群体将开始朝着在前几代中可以找到最佳解决方案的方向一致。这是因为在选择过程中,不太适合的解决方案将被删除,为交叉和变异过程中产生的新的、更适合的解决方案让路(图 1-9 )。

A978-1-4842-0328-6_1_Fig9_HTML.jpg

图 1-9。

The fitness diagram after some generations have mutated

变异算子也起到了避免局部最优的作用。变异允许解从当前位置跳到搜索空间的另一个位置。这个过程通常会导致在搜索空间的更优区域中发现更合适的解决方案。

因素

尽管所有的遗传算法都是基于相同的概念,但是它们的具体实现可能会有很大的不同。具体实现的变化方式之一是它们的参数。一个基本的遗传算法至少有几个参数需要在实现过程中考虑。主要的三个是突变率、种群大小,第三个是交叉率。

突变率

突变率是溶液染色体中特定基因发生突变的概率。从技术上讲,遗传算法的突变率没有正确的值,但一些突变率会提供比其他突变率好得多的结果。更高的突变率允许群体中有更多的遗传多样性,也可以帮助算法避免局部最优。然而,过高的突变率会导致每一代之间的遗传变异过多,导致它失去在先前种群中找到的好解。

如果变异率太低,算法会花费不合理的长时间在搜索空间中移动,阻碍其找到满意解的能力。过高的突变率也会延长找到可接受的解决方案的时间。虽然,高变异率可以帮助遗传算法避免陷入局部最优,但当它设置得太高时,会对搜索产生负面影响。如前所述,这是由于每一代中的解决方案都发生了很大程度的突变,以至于在应用突变后它们实际上是随机化的。

为了理解为什么一个良好配置的突变率是重要的,考虑两个二进制编码的候选解,“100”和“101”。没有突变,新的解决方案只能来自交叉。然而,当我们交叉我们的解决方案时,后代只有两种可能的结果,“100”或“101”。这是因为父母基因组的唯一差异可以在他们的最后一位找到。如果子代从第一个父代接收到最后一位,它将是“1”,否则如果它来自第二个父代,它将是“0”。如果算法需要找到一个替代解决方案,它需要对现有的解决方案进行变异,给它提供基因库中其他地方没有的新的遗传信息。

变异率应设置为一个值,该值允许足够的多样性以防止算法停滞,但又不至于导致算法丢失来自先前种群的有价值的遗传信息。这种平衡将取决于所解决问题的性质。

群体大小

群体大小就是任何一代遗传算法群体中的个体数量。群体的规模越大,算法可以采样的搜索空间就越大。这将有助于将 it 引向更准确、全局最优的解决方案。较小的群体规模通常会导致算法在搜索空间的局部最优区域中找到不太理想的解决方案,然而它们每一代需要较少的计算资源。

同样,与变异率一样,需要找到一个平衡点,以优化遗传算法的性能。同样,所需的人口规模将根据所解决问题的性质而变化。大型丘陵搜索空间通常需要更大的群体规模来找到最佳解决方案。有趣的是,当选择一个群体规模时,存在一个点,在这个点上,增加规模将不再为算法提供它所找到的解决方案的准确性的很大改进。相反,由于处理额外的个体需要额外的计算需求,这将降低执行速度。围绕这一转变的人口规模通常会提供资源和结果之间的最佳平衡。

交叉率

应用交叉的频率也对遗传算法的整体性能有影响。改变交叉率可以调整种群中的解应用交叉算子的机会。高速率允许在交叉阶段发现许多新的、潜在的更好的解决方案。较低的比率将有助于保持健康个体的遗传信息完整无缺地传给下一代。交叉率通常应该设置为一个合理的高比率,以促进对新解决方案的搜索,同时允许一小部分人在下一代中不受影响。

基因表达

除了参数之外,影响遗传算法性能的另一个因素是所使用的遗传表示。这是遗传信息在染色体中编码的方式。更好的表示将解决方案编码成既有表现力又易于演化的方式。Holland(1975)的遗传算法基于二进制遗传表示。他提议使用由包含 0 和 1 的字符串组成的染色体。这种二进制表示可能是可用的最简单的编码,但是对于许多问题来说,它的表达能力不足以成为合适的首选。考虑这样一个例子,其中二进制表示用于编码一个整数,该整数被优化用于某个函数。在本例中,“000”代表 0,“111”代表 7,这在二进制中很常见。如果染色体中的第一个基因发生突变——通过将该位从 0 翻转到 1,或从 1 翻转到 0——它会将编码值改变 4(“111”= 7,“011”= 3)。然而,如果染色体中的最后一个基因被改变,它只会影响编码值 1(“111”= 7,“110”= 6)。这里,变异算子对候选解有不同的影响,这取决于它的染色体中的哪个基因被操作。这种差异并不理想,因为它会降低算法的性能和可预测性。对于这个例子,使用一个整数和一个互补的变异操作符会更好,它可以对基因值增加或减少相对较小的数量。

除了简单的二进制表示和整数,遗传算法还可以使用:浮点数、基于树的表示、对象以及遗传编码所需的任何其他数据结构。当构建有效的遗传算法时,选择正确的表示是关键。

结束

无论需要多长时间,遗传算法都可以继续进化出新的候选解。根据问题的性质,遗传算法可以在任何地方运行几秒到几年!我们称遗传算法完成搜索的条件为终止条件。

一些典型的终止条件是:

  • 达到了最大代数
  • 已经超过了分配给它的时间限制
  • 已找到满足所需标准的解决方案
  • 该算法已达到稳定状态

有时,实现多个终止条件可能更好。例如,如果找到了适当的解决方案,可以方便地设定一个最大时间限制,并有可能提前终止。

搜索过程

为了结束这一章,让我们一步一步地看看遗传算法背后的基本过程,如图 1-10 所示。

Genetic algorithms begin by initializing a population of candidate solutions. This is typically done randomly to provide an even coverage of the entire search space.   Next, the population is evaluated by assigning a fitness value to each individual in the population. In this stage we would often want to take note of the current fittest solution, and the average fitness of the population.   After evaluation, the algorithm decides whether it should terminate the search depending on the termination conditions set. Usually this will be because the algorithm has reached a fixed number of generations or an adequate solution has been found.   If the termination condition is not met, the population goes through a selection stage in which individuals from the population are selected based on their fitness score – the higher the fitness, the better chance an individual has of being selected.   The next stage is to apply crossover and mutation to the selected individuals. This stage is where new individuals are created for the next generation.   At this point the new population goes back to the evaluation step and the process starts again. We call each cycle of this loop a generation.   When the termination condition is finally met, the algorithm will break out of the loop and typically return its finial search results back to the user.  

A978-1-4842-0328-6_1_Fig10_HTML.jpg

图 1-10。

A general genetic algorithm process

引文

A.M .图灵(1950)。“计算机器和智能”

西蒙,H.A. (1965)。“人和管理的自动化形态”

巴里塞尔,北卡罗来纳州(1975 年)。“通过人工方法实现的共生进化过程”

达尔文,C. (1859)。《物种起源》

Dorigo,M. (1992 年)。优化、学习和自然算法

雷森博格,I. (1965)“一个实验问题的控制论解决途径”

compute Berg,I. (1973)“进化战略:根据生物进化原理优化技术系统”

硫 h-p(1975 年)"进化策略与数值优化"

硫磺,h-p .(1977 年)“利用进化策略对计算机模型进行数值优化”

福格尔 L . J;欧文斯,A . J;和沃尔什,M.J. (1966)“通过模拟进化的人工智能”

霍兰德,J.H. (1975)“自然和人工系统中的适应”

m . dorigo(1992)“最优化、学习和自然算法”

Glover,F. (1989)“禁忌搜索。第一部分"

Glover,F. (1990)“禁忌搜索。第二部分"

柯克帕特里克,S;Gelatt,C . D . Jr .和 Vecchi,M.P. (1983)“模拟退火优化”

二、一种基本遗传算法的实现

在这一章中,我们将开始探索用于实现基本遗传算法的技术。我们在这里开发的程序将被修改,在本书的后续章节中增加新的特性。我们还将探索遗传算法的性能如何随其参数和配置而变化。

要按照本节中的代码进行操作,您需要首先在您的计算机上安装 Java JDK。您可以从 Oracle 网站免费下载并安装 Java JDK:

oracle.com/technetwork/java/javase/downloads/index.html

尽管不是必需的,但是除了安装 Java JDK 之外,为了方便起见,您还可以选择安装一个 Java 兼容的 IDE,比如 Eclipse 或 NetBeans。

预实施

在实现遗传算法之前,最好先考虑一下遗传算法是否是完成手头任务的正确方法。通常会有更好的技术来解决特定的优化问题,通常是通过利用一些领域相关的试探法。遗传算法是独立于领域的,或“弱方法”,它可以应用于问题,而不需要任何特定的先验知识来帮助其搜索过程。由于这个原因,如果没有任何已知的特定领域的知识来帮助指导搜索过程,遗传算法仍然可以用来发现潜在的解决方案。

当已经确定弱搜索方法是合适的时,还应该考虑所使用的弱方法的类型。这可能仅仅是因为替代方法提供了平均更好的结果,但也可能是因为替代方法更容易实现,需要更少的计算资源,或者可以在更短的时间内找到足够好的结果。

基本遗传算法的伪代码

基本遗传算法的伪代码如下:

1: generation = 0;

2: population[generation] = initializePopulation(populationSize);

3: evaluatePopulation(population[generation]);

3:``While``isTerminationConditionMet() == false

4:     parents = selectParents(population[generation]);

5:    population[generation+1] = crossover(parents);

6:   population[generation+1] = mutate(population[generation+1]);

7:    evaluatePopulation(population[generation]);

8:     generation++;

9: End loop;

伪代码从创建遗传算法的初始种群开始。然后对这个群体进行评估,以找到其个体的适合度值。接下来,运行检查以决定是否满足遗传算法的终止条件。如果没有,遗传算法开始循环,种群在最终被重新评估之前经历第一轮交叉和变异。从这里开始,交叉和变异不断地被应用,直到满足终止条件,遗传算法终止。

这段伪代码演示了遗传算法的基本过程;然而,我们有必要更详细地研究每一步,以充分理解如何创建一个令人满意的遗传算法。

关于本书中的代码示例

本书中的每一章都被表示为 Eclipse 项目中的一个包。每个包至少有四个类别:

  • GeneticAlgorithm 类,它抽象了遗传算法本身,并提供了接口方法的特定问题实现,如交叉、变异、适应性评估和终止条件检查。
  • 一个单独的类,代表单个候选解及其染色体。
  • 人口类,代表人口或一代人,并对他们应用组级操作。
  • 一个包含“main”方法、一些引导代码、上述伪代码的具体版本以及特定问题可能需要的任何支持工作的类。这些类将根据它所解决的问题来命名,例如“AllOnesGA”、“RobotController”等。

你最初在这一章中写的遗传算法、种群和个体类将需要在本书后面的每一章中进行修改。

您可以想象这些类实际上是接口的具体实现,如 GeneticAlgorithmInterface、PopulationInterface 和 individual interface——然而,我们保持了 Eclipse 项目的简单布局,避免使用接口。

你将在本书中找到的遗传算法类将总是实现许多重要的方法,如“calcFitness”、“evalPopulation”、“isTerminationConditionMet”、“crossoverPopulation”和“mutatePopulation”。然而,根据手头问题的要求,这些方法的内容在每章中会略有不同。

在遵循本书中的例子时,我们建议将遗传算法、种群和个体类复制到每个新问题中,因为一些方法的实现将在不同的章节中保持相同,但其他的会有所不同。

另外,请务必阅读所附 Eclipse 项目中源代码中的注释!为了节省本书的篇幅,我们省略了冗长的注释和文档块,但是在可供下载的 Eclipse 文件中,我们非常小心地对源代码进行了完整的注释。就像有了第二本书可以读!

在许多情况下,本书的章节会要求你在一个类中添加或修改一个方法。一般来说,在文件中的什么地方添加一个新方法并不重要,所以在这些情况下,我们要么从例子中省略掉类的其余部分,要么只显示函数签名来帮助你理解。

基本实现

为了删除任何不必要的细节,并保持初始实现易于遵循,我们将在本书中涵盖的第一个遗传算法将是一个简单的二进制遗传算法。

二进制遗传算法相对容易实现,并且是解决各种优化问题的非常有效的工具。你可能从第一章中还记得,二进制遗传算法是 Holland (1975)提出的遗传算法的最初范畴。

问题

首先,让我们回顾一下“全 1”问题,这是一个非常基本的问题,可以使用二进制遗传算法来解决。

这个问题不是很有趣,但它作为一个简单的问题,有助于强调所涉及的基本技术。顾名思义,问题就是找到一个完全由 1 组成的字符串。因此,对于长度为 5 的字符串,最佳解决方案是“11111”。

因素

现在我们有一个问题要解决,让我们继续执行。我们要做的第一件事是设置遗传算法参数。如前所述,三个主要参数是群体大小、突变率和交叉率。在本章中,我们还引入了一个叫做“精英主义”的概念,并将它作为遗传算法的参数之一。

首先,创建一个名为 GeneticAlgorithm 的类。如果您使用的是 Eclipse,您可以通过选择文件➤新➤类来实现。我们已经选择根据本书中的章节号来命名包,因此我们将在包“第章第二章”中工作。

这个 GeneticAlgorithm 类将包含遗传算法本身操作所需的方法和变量。例如,这个类包括处理交叉、变异、适应性评估和终止条件检查的逻辑。创建类之后,添加一个接受四个参数的构造函数:种群大小、突变率、交叉率和精英成员的数量。

package chapter2;

/**

* Lots of comments in the source that are omitted here!

*/

public class GeneticAlgorithm {

private int populationSize;

private double mutationRate;

private double crossoverRate;

private int elitismCount;

public GeneticAlgorithm(int populationSize, double mutationRate, double crossoverRate, int elitismCount) {

this.populationSize = populationSize;

this.mutationRate = mutationRate;

this.crossoverRate = crossoverRate;

this.elitismCount = elitismCount;

}

/**

* Many more methods implemented later...

*/

}

当传递了所需的参数时,此构造函数将使用所需的配置创建 GeneticAlgorithm 类的新实例。

现在我们应该创建我们的引导类——回想一下,每章都需要一个引导类来初始化遗传算法,并为应用提供一个起点。将该类命名为“AllOnesGA ”,并定义一个“main”方法:

package chapter2;

public class AllOnesGA {

public static void main(String[] args) {

// Create GA object

GeneticAlgorithm ga = new GeneticAlgorithm(100, 0.01, 0.95, 0);

// We’ll add a lot more here...

}

}

目前,我们将只使用参数的一些典型值,人口规模= 100;突变率= 0.01;交叉率= 0.95,精英主义计数为 0(有效地禁用它——目前)。在完成本章末尾的实现后,您可以试验如何更改这些参数来影响算法的性能。

初始化

我们的下一步是初始化潜在解决方案的群体。这通常是随机进行的,但偶尔可能更好的是更系统地初始化群体,可能利用关于搜索空间的已知信息。在这个例子中,群体中的每个个体将被随机初始化。我们可以通过为染色体中的每个基因随机选择值 1 或 0 来做到这一点。

在初始化群体之前,我们需要创建两个类,一个用于管理和创建群体,另一个用于管理和创建群体的个体。例如,正是这些类包含了获取个体适应性的方法,或者获取种群中最适合的个体。

首先让我们从创建单独的类开始。注意,为了节省纸张,我们省略了下面所有的注释和方法文档块!您可以在附带的 Eclipse 项目中找到这个类的完整注释版本。

package chapter2;

public class Individual {

private int[] chromosome;

private double fitness = -1;

public Individual(int[] chromosome) {

// Create individual chromosome

this.chromosome = chromosome;

}

public Individual(int chromosomeLength) {

this.chromosome = new int[chromosomeLength];

for (int gene = 0; gene < chromosomeLength; gene++) {

if (0.5 < Math.random()) {

this.setGene(gene, 1);

} else {

this.setGene(gene, 0);

}

}

}

public int[] getChromosome() {

return this.chromosome;

}

public int getChromosomeLength() {

return this.chromosome.length;

}

public void setGene(int offset, int gene) {

this.chromosome[offset] = gene;

}

public int getGene(int offset) {

return this.chromosome[offset];

}

public void setFitness(double fitness) {

this.fitness = fitness;

}

public double getFitness() {

return this.fitness;

}

public String toString() {

String output = "";

for (int gene = 0; gene < this.chromosome.length; gene++) {

output += this.chromosome[gene];

}

return output;

}

}

单个类代表单个候选解,主要负责存储和操作染色体。注意,单个类也有两个构造函数。一个构造函数接受一个整数(代表染色体的长度),并在初始化对象时创建一个随机染色体。另一个构造函数接受一个整数数组,并将其用作染色体。

除了管理个体的染色体之外,它还跟踪个体的适应值,并且知道如何将自身打印为字符串。

下一步是创建 Population 类,它提供管理群体中一组个体所需的功能。

像往常一样,本章省略了注释和文档块;请务必查看 Eclipse 项目以了解更多上下文!

package chapter2;

import java.util.Arrays;

import java.util.Comparator;

public class Population {

private Individual population[];

private double populationFitness = -1;

public Population(int populationSize) {

this.population = new Individual[populationSize];

}

public Population(int populationSize, int chromosomeLength) {

this.population = new Individual[populationSize];

for (int individualCount = 0; individualCount < populationSize; individualCount++) {

Individual individual = new Individual(chromosomeLength);

this.population[individualCount] = individual;

}

}

public Individual[] getIndividuals() {

return this.population;

}

public Individual getFittest(int offset) {

Arrays.sort(this.population, new Comparator<Individual>() {

@Override

public int compare(Individual o1, Individual o2) {

if (o1.getFitness() > o2.getFitness()) {

return -1;

} else if (o1.getFitness() < o2.getFitness()) {

return 1;

}

return 0;

}

});

return this.population[offset];

}

public void setPopulationFitness(double fitness) {

this.populationFitness = fitness;

}

public double getPopulationFitness() {

return this.populationFitness;

}

public int size() {

return this.population.length;

}

public Individual setIndividual(int offset, Individual individual) {

return population[offset] = individual;

}

public Individual getIndividual(int offset) {

return population[offset];

}

public void shuffle() {

Random rnd = new Random();

for (int i = population.length - 1; i > 0; i--) {

int index = rnd.nextInt(i + 1);

Individual a = population[index];

population[index] = population[i];

population[i] = a;

}

}

}

人口阶层相当简单;它的主要功能是保存一个个体数组,需要时可以通过类方法方便地访问这些个体。诸如 getFittest()和 setIndividual()的方法是可以访问和更新群体中的个体的方法的例子。除了保存个体之外,它还存储了群体的总适应度,这将在以后实施选择方法时变得很重要。

现在我们有了种群和个体类,我们可以在 GeneticAlgorithm 类中实现它们。为此,只需在 GeneticAlgorithm 类中的任意位置创建一个名为“initPopulatio”的方法。

public class GeneticAlgorithm {

/**

* The constructor we created earlier is up here...

*/

public Population initPopulation(int chromosomeLength) {

Population population = new Population(this.populationSize, chromosomeLength);

return population;

}

/**

* We still have lots of methods to implement down here...

*/

}

现在我们有了一个群体和一个个体类,我们可以返回到我们的“AllOnesGA”类,并开始使用“initPopulation”方法。回想一下,“AllOnesGA”类只有一个“main”方法,它代表本章前面提到的伪代码。

在 main 方法中初始化群体时,我们还需要指定个体染色体的长度——这里我们将使用 50:

public class AllOnesGA {

public static void main(String[] args){

// Create GA object

GeneticAlgorithm ga = new GeneticAlgorithm(100, 0.01, 0.95, 0);

// Initialize population

Population population = ga.initPopulation(50);

}

}

估价

在评估阶段,计算群体中每个个体的适应值并存储以备将来使用。为了计算个体的适应度,我们使用一个被称为“适应度函数”的函数。

遗传算法通过使用选择来引导进化过程朝向更好的个体。因为是适应度函数使这种选择成为可能,所以适应度函数设计得好并为个人的适应度提供准确的值是很重要的。如果适应度函数设计得不好,可能要花更长时间才能找到满足最低标准的解决方案,或者根本找不到可接受的解决方案。

适应度函数通常是遗传算法中计算量最大的部分。正因为如此,适应度函数也得到很好的优化以帮助防止瓶颈并允许算法高效运行是很重要的。

每个特定的优化问题都需要一个独特的适应度函数。在我们的全 1 问题的例子中,适应度函数相当简单,简单地计算在一个个体的染色体中发现的 1 的数量。

现在向 GeneticAlgorithm 类添加一个 calcFitness 方法。这个方法应该计算染色体中 1 的数量,然后通过除以染色体长度将输出归一化到 0 和 1 之间。您可以在 GeneticAlgorithm 类中的任何位置添加此方法,因此我们省略了下面的相关代码:

public double calcFitness(Individual individual) {

// Track number of correct genes

int correctGenes = 0;

// Loop over individual’s genes

for (int geneIndex = 0; geneIndex < individual.getChromosomeLength(); geneIndex++) {

// Add one fitness point for each "1" found

if (individual.getGene(geneIndex) == 1) {

correctGenes += 1;

}

}

// Calculate fitness

double fitness = (double) correctGenes / individual.getChromosomeLength();

// Store fitness

individual.setFitness(fitness);

return fitness;

}

我们还需要一个简单的助手方法来循环遍历群体中的每个个体并对他们进行评估(例如,对每个个体调用 calcFitness)。让我们将这个方法称为 evalPopulation,并将其添加到 GeneticAlgorithm 类中。它应该如下所示,同样,您可以在任何地方添加它:

public void evalPopulation(Population population) {

double populationFitness = 0;

for (Individual individual : population.getIndividuals()) {

populationFitness += calcFitness(individual);

}

population.setPopulationFitness(populationFitness);

}

此时,GeneticAlgorithm 类中应该有以下方法。为了简洁起见,我们省略了函数体,只显示了该类的折叠视图:

package chapter2;

public class GeneticAlgorithm {

private int populationSize;

private double mutationRate;

private double crossoverRate;

private int elitismCount;

public GeneticAlgorithm(int populationSize, double mutationRate, double crossoverRate, int elitismCount) { }

public Population initPopulation(int chromosomeLength) { }

public double calcFitness(Individual individual) { }

public void evalPopulation(Population population) { }

}

如果您缺少这些属性或方法中的任何一个,请现在返回并实现它们。我们在 GeneticAlgorithm 类中还需要实现四个方法:isTerminationConditionMet、selectParent、crossoverPopulation 和 mutatePopulation。

终止检查

接下来需要检查我们的终止条件是否已经满足。有许多不同类型的终止条件。有时候,有可能知道最优解是什么(更确切地说,有可能知道最优解的适应值),在这种情况下,我们可以直接检查正确的解。然而,并不总是能够知道最佳解决方案的适合度是多少,因此我们可以在解决方案变得“足够好”时终止;也就是说,每当解决方案超过某个适合度阈值时。当算法已经运行了太长时间(太多代)时,我们也可以终止,或者当决定终止算法时,我们可以结合许多因素。

由于全 1 问题的简单性,以及我们知道正确的适应度应该是 1 的事实,在这种情况下,当找到正确的解时终止是合理的。不会一直这样的!事实上,这种情况很少发生——但是我们很幸运这是一个简单的问题。

首先,我们必须先构造一个函数来检查我们的终止条件是否已经发生。我们可以通过向 GeneticAlgorithm 类添加以下代码来实现这一点。在任何地方添加它,为了简洁起见,我们像往常一样省略了周围的类。

public boolean isTerminationConditionMet(Population population) {

for (Individual individual : population.getIndividuals()) {

if (individual.getFitness() == 1) {

return true;

}

}

return false;

}

上面的方法检查群体中的每个个体,如果群体中任何个体的适合度为 1,将返回 true 表明我们已经找到了终止条件,可以停止。

现在已经建立了终止条件,可以使用新添加的终止检查作为循环条件,将循环添加到 AllOnesGA 类的主 bootstrap 方法中。当终止检查返回真时,遗传算法将停止循环并返回其结果。

为了创建演化循环,修改我们的 executive AllOnesGA 类的 main 方法来表示如下内容。下面代码片段的前两行已经在 main 方法中了。通过添加这些代码,我们将继续实现本章开头给出的伪代码——回想一下,“main”方法是遗传算法伪代码的具体表示。主方法现在应该是这样的:

public static void main(String[] args) {

// These two lines were already here:

GeneticAlgorithm ga = new GeneticAlgorithm(100, 0.001, 0.95, 0);

Population population = ga.initPopulation(50);

// The following is the new code you should be adding:

ga.evalPopulation(population);

int generation = 1;

while (ga.isTerminationConditionMet(population) == false) {

// Print fittest individual from population

System.out.println("Best solution: " + population.getFittest(0).toString());

// Apply crossover

// TODO!

// Apply mutation

// TODO!

// Evaluate population

ga.evalPopulation(population);

// Increment the current generation

generation++;

}

System.out.println("Found solution in " + generation + " generations");

System.out.println("Best solution: " + population.getFittest(0).toString());

}

我们添加了一个 evolution 循环来检查 isTerminationConditionMet 的输出。main 方法的另一个新特性是在循环之前和循环期间增加了 evalPopulation 调用,生成变量跟踪生成号,调试消息帮助您了解每一代中的最佳解决方案。

我们还添加了一个结束游戏:当我们退出循环时,我们将打印一些关于最终解决方案的信息。

然而,在这一点上,我们的遗传算法将运行,但它永远不会进化!我们将陷入一个无限循环,除非我们足够幸运,随机生成的个体中有一个恰好全是 1。在 Eclipse 中点击“运行”按钮可以直接看到这种行为;相同的解决方案将会一遍又一遍地出现,循环永无止境。您必须通过点击 Eclipse 控制台上方的“终止”按钮来强制程序停止运行。

为了继续构建我们的遗传算法,我们需要实现两个额外的概念:交叉和变异。这些概念实际上通过随机突变和适者生存推动了种群的进化。

交叉

此时,是时候开始通过应用变异和交叉来进化种群了。交叉算子是群体中的个体交换遗传信息的过程,希望创造出一个包含其父母基因组中最好部分的新个体。

在交叉过程中,考虑群体中的每个个体进行交叉;这就是使用交叉率参数的地方。通过将交叉率与一个随机数进行比较,我们可以决定是否应该对该个体应用交叉,或者是否应该将其直接添加到不受交叉影响的下一个种群中。如果一个个体被选择进行杂交,那么就需要找到第二个亲本。为了找到第二个父母,我们需要从许多可能的选择方法中选择一个。

轮盘赌选择

轮盘赌轮选择-也称为适合度比例选择-是一种选择方法,它使用轮盘赌轮的类比来从群体中选择个体。这个想法是,根据个体的适应度值,将群体中的个体放在隐喻的轮盘赌上。个体的适应度越高,轮盘上分配的空间就越大。下图展示了个人在这一过程中的典型定位。

A978-1-4842-0328-6_2_Figa_HTML.jpg

上面轮子上的每个数字代表了群体中的一个个体。个人的健康程度越高,他们在轮盘赌中的份额就越大。如果你现在想象旋转这个轮子,更有可能的是更健康的个体会被选中,因为他们占据了轮子上更多的空间。这就是为什么这种选择方法通常被称为适合度比例选择;因为解决方案的选择是基于它们的适合度与其余人群的适合度的比例。

我们可以使用许多其他选择方法,例如:锦标赛选择(第三章)和随机通用抽样(一种高级形式的健康比例选择)。然而,在这一章中,我们将实现一个最常见的选择方法:轮盘赌选择。在后面的章节中,我们将会看到其他的选择方法以及它们之间的区别。

交叉方法

除了在杂交过程中可以使用的各种选择方法之外,还有不同的方法来交换两个个体之间的遗传信息。不同的问题具有稍微不同的性质,并且使用特定的交叉方法效果更好。例如,全 1 问题只需要一个完全由 1 组成的字符串。一串“00111”与一串“10101”具有相同的适应值——它们都包含三个 1。对于这种类型的遗传算法,情况并不总是这样。假设我们试图创建一个字符串,这个字符串按照数字 1 到 5 的顺序排列。在这种情况下,字符串“12345”具有与“52431”非常不同的适合度值。这是因为我们不仅要寻找正确的数字,还要寻找正确的顺序。对于这样的问题,尊重基因顺序的杂交方法是更可取的。

我们将在这里实现的交叉方法是均匀交叉。在这种方法中,后代的每个基因有 50%的变化来自其第一个父母或第二个父母。

A978-1-4842-0328-6_2_Figb_HTML.jpg

交叉伪码

现在我们有了一个选择和交叉方法,让我们看一些伪代码,这些代码概述了要实现的交叉过程。

1:``For each``individual``in

2:      newPopulation = new array;

2:``If

3:             secondParent = selectParent();

4:            offspring = crossover(individual, secondParent);

5:            newPopulation.push(offspring);

6: Else:

7:            newPopulation.push(individual);

8: End if

9: End loop;

交叉实施

若要实现轮盘赌选择,请在 GeneticAlgorithm 类中的任意位置添加一个 selectParent()方法。

public Individual selectParent(Population population) {

// Get individuals

Individual individuals[] = population.getIndividuals();

// Spin roulette wheel

double populationFitness = population.getPopulationFitness();

double rouletteWheelPosition = Math.random() * populationFitness;

// Find parent

double spinWheel = 0;

for (Individual individual : individuals) {

spinWheel += individual.getFitness();

if (spinWheel >= rouletteWheelPosition) {

return individual;

}

}

return individuals[population.size() - 1];

}

selectParent()方法实质上是反向运行一个轮盘赌;在赌场,轮盘上已经有标记,然后你旋转轮盘,等待球落入位置。然而,在这里,我们首先选择一个随机的位置,然后反向工作以计算出哪个个体位于该位置。从算法上来说,这样更简单。在 0 和总群体适应度之间选择一个随机数,然后遍历每个个体,一边走一边计算他们的适应度,直到到达开始时选择的随机位置。

既然已经添加了选择方法,下一步就是使用 selectParent()方法创建交叉方法来选择交叉配对。首先,将以下交叉方法添加到 GeneticAlgorithm 类中。

public Population crossoverPopulation(Population population) {

// Create new population

Population newPopulation = new Population(population.size());

// Loop over current population by fitness

for (int populationIndex = 0; populationIndex < population.size(); populationIndex++) {

Individual parent1 = population.getFittest(populationIndex);

// Apply crossover to this individual?

if (this.crossoverRate > Math.random() && populationIndex > this.elitismCount) {

// Initialize offspring

Individual offspring = new Individual(parent1.getChromosomeLength());

// Find second parent

Individual parent2 = selectParent(population);

// Loop over genome

for (int geneIndex = 0; geneIndex < parent1.getChromosomeLength(); geneIndex++) {

// Use half of parent1's genes and half of parent2's genes

if (0.5 > Math.random()) {

offspring.setGene(geneIndex, parent1.getGene(geneIndex));

} else {

offspring.setGene(geneIndex, parent2.getGene(geneIndex));

}

}

// Add offspring to new population

newPopulation.setIndividual(populationIndex, offspring);

} else {

// Add individual to new population without applying crossover

newPopulation.setIndividual(populationIndex, parent1);

}

}

return newPopulation;

}

在 crossoverPopulation()方法的第一行中,为下一代创建了一个新的空群体。接下来,对种群进行循环,并使用交叉率来考虑每个个体的交叉。(这里还有一个神秘的“精英主义”术语,我们将在下一节讨论。)如果个体没有通过交叉,它直接被添加到下一个种群,否则产生一个新的个体。后代的染色体是通过在亲代染色体上循环并随机将来自每个亲代的基因添加到后代的染色体上来填充的。当对群体中的每个个体完成这种交叉过程时,交叉方法返回下一代群体。

从这里我们可以在 AllOnesGA 类的 main 方法中实现 crossover 函数。下面显示了整个 AllOnesGA 类和 main 方法;然而,与之前唯一的变化是在“应用交叉”注释下面添加了一行调用 crossoverPopulation()的代码。

package chapter2;

public class AllOnesGA {

public static void main(String[] args) {

// Create GA object

GeneticAlgorithm ga = new GeneticAlgorithm(100, 0.001, 0.95, 0);

// Initialize population

Population population = ga.initPopulation(50);

// Evaluate population

ga.evalPopulation(population);

// Keep track of current generation

int generation = 1;

while (ga.isTerminationConditionMet(population) == false) {

// Print fittest individual from population

System.out.println("Best solution: " + population.getFittest(0).toString());

// Apply crossover

population = ga.crossoverPopulation(population);

// Apply mutation

// TODO

// Evaluate population

ga.evalPopulation(population);

// Increment the current generation

generation++;

}

System.out.println("Found solution in " + generation + " generations");

System.out.println("Best solution: " + population.getFittest(0).toString());

}

}

此时,运行程序应该工作并返回一个有效的解!通过单击 Eclipse 中的 Run 按钮并观察出现的控制台,亲自尝试一下。

如你所见,光是杂交就足以进化出一个种群。然而,没有变异的遗传算法容易陷入局部最优,永远找不到全局最优。我们不会在如此简单的问题中看到这一点,但在更复杂的问题领域中,我们需要一些机制来推动群体远离局部最优,以尝试看看是否有更好的解决方案。这就是突变的随机性发挥作用的地方:如果一个解决方案在局部最优附近停滞不前,一个随机事件可能会将它踢向正确的方向,并将其送往更好的解决方案。

精英主义

在讨论变异之前,我们先来看看我们在交叉方法中引入的“elitismCount”参数。

由于交叉和变异算子的影响,基本遗传算法经常会在两代之间丢失种群中的最佳个体。然而,我们需要这些运营商找到更好的解决方案。要了解这个问题的实际情况,只需编辑您的遗传算法代码,打印出每一代中最适合的个体的适应性。您会注意到,虽然它通常会上升,但在交叉和变异过程中,有时会丢失最合适的解决方案,而代之以不太理想的解决方案。

用于解决这个问题的一个简单的优化技术是总是允许最适合的一个或多个个体被不加改变地添加到下一代群体中。这样,最优秀的个体就不会代代相传。尽管这些个体没有应用交叉,但是它们仍然可以被选择作为另一个个体的亲本,允许它们的遗传信息仍然与群体中的其他人共享。这个为下一代保留最好的过程被称为精英主义。

通常情况下,种群中“精英”个体的最佳数量在总种群规模中所占的比例非常小。这是因为如果该值太高,它将由于保留太多个体导致的遗传多样性的缺乏而减慢遗传算法的搜索过程。与前面讨论的其他参数类似,找到最佳性能的平衡点很重要。

实施精英主义在交叉和变异环境中都很简单。让我们重新看看 crossoverPopulation()中的条件,它检查是否应该应用交叉:

// Apply crossover to this individual?

if (this.crossoverRate > Math.random() && populationIndex >= this.elitismCount) {

// ...

}

交叉仅适用于交叉条件得到满足且个人不被视为精英的情况。

是什么造就了个人精英?此时,群体中的个体已经按其适应度排序,因此最强的个体具有最低的指数。因此,如果我们想要三个精英个体,我们应该从考虑中跳过指数 0-2。这将保留最强壮的个体,并让它们不加修改地传递给下一代。我们将在接下来的变异代码中使用相同的精确条件。

变化

我们完成进化过程需要添加的最后一件事是突变。像交叉一样,有许多不同的变异方法可供选择。当使用二进制字符串时,一种更常用的方法叫做位翻转突变。您可能已经猜到,位翻转突变涉及将位的值从 1 翻转到 0,或者从 0 翻转到 1,这取决于它的初始值。当染色体使用一些其他表示法编码时,通常会实施不同的突变方法来更好地利用编码。

在选择变异和交叉方法时,最重要的因素之一是确保您选择的方法仍能产生有效的解决方案。我们将在后面的章节中看到这个概念的应用,但是对于这个问题,我们只需要确定 0 和 1 是基因突变的唯一可能值。比如说,一个基因突变到 7 会给我们一个无效的解决方案。

这个建议在本章中似乎没有实际意义而且过于明显,但是考虑一个不同的简单问题,其中您需要对数字 1 到 6 进行排序而不重复(例如,以“123456”结束)。一个简单地在 1 到 6 之间选择一个随机数的突变算法可以产生“126456”,使用“6”两次,这将是一个无效的解决方案,因为每个数字只能使用一次。如你所见,即使是简单的问题有时也需要复杂的技术。

与交叉类似,变异是基于变异率应用于个体的。如果突变率设置为 0.1,那么每个基因在突变阶段有 10%的几率发生突变。

让我们继续将变异函数添加到我们的遗传算法类中。我们可以在任何地方添加这个:

public Population mutatePopulation(Population population) {

// Initialize new population

Population newPopulation = new Population(this.populationSize);

// Loop over current population by fitness

for (int populationIndex = 0; populationIndex < population.size(); populationIndex++) {

Individual individual = population.getFittest(populationIndex);

// Loop over individual’s genes

for (int geneIndex = 0; geneIndex < individual.getChromosomeLength(); geneIndex++) {

// Skip mutation if this is an elite individual

if (populationIndex >= this.elitismCount) {

// Does this gene need mutation?

if (this.mutationRate > Math.random()) {

// Get new gene

int newGene = 1;

if (individual.getGene(geneIndex) == 1) {

newGene = 0;

}

// Mutate gene

individual.setGene(geneIndex, newGene);

}

}

}

// Add individual to population

newPopulation.setIndividual(populationIndex, individual);

}

// Return mutated population

return newPopulation;

}

mutatePopulation()方法首先为变异个体创建一个新的空群体,然后开始遍历当前群体。然后循环每个个体的染色体,并使用突变率考虑每个基因的位翻转突变。当一个个体的整个染色体被循环时,这个个体就被加入到新的突变群体中。当所有个体都经历了变异过程后,变异的群体被返回。

现在,我们可以通过向 main 方法添加 mutate 函数来完成进化循环的最后一步。完成的主要方法如下。与上次相比,只有两处不同:首先,我们在“应用变异”注释下面添加了对 mutatePopulation()的调用。此外,我们已经将“new GeneticAlgorithm”构造函数中的“elitismCount”参数从 0 更改为 2,现在我们已经了解了精英主义是如何工作的。

package chapter2;

public class AllOnesGA {

public static void main(String[] args) {

// Create GA object

GeneticAlgorithm ga = new GeneticAlgorithm(100, 0.001, 0.95, 2);

// Initialize population

Population population = ga.initPopulation(50);

// Evaluate population

ga.evalPopulation(population);

// Keep track of current generation

int generation = 1;

while (ga.isTerminationConditionMet(population) == false) {

// Print fittest individual from population

System.out.println("Best solution: " + population.getFittest(0).toString());

// Apply crossover

population = ga.crossoverPopulation(population);

// Apply mutation

population = ga.mutatePopulation(population);

// Evaluate population

ga.evalPopulation(population);

// Increment the current generation

generation++;

}

System.out.println("Found solution in " + generation + " generations");

System.out.println("Best solution: " + population.getFittest(0).toString());

}

}

执行

你现在已经完成了你的第一个遗传算法。个人和群体类在本章的前面已经完整地打印出来了,你的版本应该和上面的一模一样。最后一个 AllOnesGA 执行类——引导和运行算法的类——就在上面。

GeneticAlgorithm 类相当长,并且您是一点一点地构建的,所以此时请检查您是否实现了以下属性和方法。为了节省空间,我在这里省略了所有的注释和方法体——我只是展示了一个类的折叠视图——但是要确保你的类版本已经如上所述实现了这些方法中的每一个。

package chapter2;

public class GeneticAlgorithm {

private int populationSize;

private double mutationRate;

private double crossoverRate;

private int elitismCount;

public GeneticAlgorithm(int populationSize, double mutationRate, double crossoverRate, int elitismCount) { }

public Population initPopulation(int chromosomeLength) { }

public double calcFitness(Individual individual) { }

public void evalPopulation(Population population) { }

public boolean isTerminationConditionMet(Population population) { }

public Individual selectParent(Population population) { }

public Population crossoverPopulation(Population population) { }

public Population mutatePopulation(Population population) { }

}

如果您使用的是 Eclipse IDE,现在可以通过打开 AllOnesGA 文件并单击“run”按钮来运行该算法,该按钮通常位于 IDE 的顶部菜单中。

运行时,算法会将信息打印到控制台,当单击 Run 时,这些信息会自动出现在 Eclipse 中。由于每种遗传算法的随机性,每次运行看起来都会有一点不同,但这里有一个例子可以说明您的输出可能是什么样子:

Best solution: 11001110100110111111010111001001100111110011111111

Best solution: 11001110100110111111010111001001100111110011111111

Best solution: 11001110100110111111010111001001100111110011111111

[ ... Lots of lines omitted here ... ]

Best solution: 11111111111111111111111111111011111111111111111111

Best solution: 11111111111111111111111111111011111111111111111111

Found solution in 113 generations

Best solution: 11111111111111111111111111111111111111111111111111

此时,您应该使用已经赋予 GeneticAlgorithm 构造函数的各种参数:populationSize、mutationRate、crossoverRate 和 elitismCount。不要忘记,统计数据决定了遗传算法的性能,所以你不能只运行一次就评估一个算法或设置的性能——在判断其性能之前,你需要对每个不同的设置运行至少 10 次试验。

摘要

在这一章中,你已经学习了实现遗传算法的基础。本章开头的伪代码为您将在本书其余部分实现的所有遗传算法提供了一个通用的概念模型:每个遗传算法将初始化并评估一个群体,然后进入一个执行交叉、变异和重新评估的循环。只有满足终止条件,循环才会退出。

在这一章中,你构建了遗传算法的支持组件,特别是个体和群体类,你将在接下来的章节中重用它们。然后,您专门构建了一个 GeneticAlgorithm 类来解决“全 1”问题,并成功运行了它。

您还学到了以下内容:虽然每个遗传算法在概念和结构上是相似的,但不同的问题域将需要不同的评估技术实现(即,适应性评分、交叉技术和变异技术)。

本书的其余部分将通过示例问题来探索这些不同的技术。在接下来的章节中,您将重用群体和个体类,只需稍加修改。然而,接下来的每一章都需要对遗传算法类进行大量修改,因为该类是交叉、变异、终止条件和适合度评估发生的地方。

ExercisesRun the genetic algorithm a few times observing the randomness of the evolutionary process. How many generations does it typically take to find a solution to this problem?   Increase and decrease the population size. How does decreasing the population size affect the speed of the algorithm and does it also affect the number of generations it takes to find a solution? How does increasing the population size affect the speed of the algorithm and how does it affect the number of generations it takes to find a solution?   Set the mutation rate to 0. How does this affect the genetic algorithms ability to find a solution? Use a high mutation rate, how does this affect the algorithm?   Apply a low crossover rate. How does the algorithm preform with a lower crossover rate?   Decrease and increase the complexity of the problem by experimenting with shorter and larger chromosomes. Do different parameters work better when dealing with shorter or larger chromosomes?   Compare the genetic algorithm’s performance with and without elitism enabled.   Run tests using high elitism values. How does this affect the search performance?

三、机器人控制器

介绍

在这一章中,我们将利用上一章学到的知识,通过遗传算法来解决一个现实世界中的问题。我们要解决的现实问题是设计机器人控制器。

遗传算法通常应用于机器人,作为设计复杂机器人控制器的方法,使机器人能够执行复杂的任务和行为,消除了手动编码复杂机器人控制器的需要。想象一下,你造了一个可以在仓库里运输货物的机器人。你已经安装了传感器,这使得机器人可以看到它的本地环境,并且你已经给了它轮子,所以它可以根据来自它的传感器的输入来导航。问题是如何将传感器数据与电机动作联系起来,以便机器人能够在仓库中导航。

遗传算法,更一般地说,达尔文进化论的思想被应用于机器人学的人工智能领域被称为进化机器人学。然而,这并不是解决这个问题的唯一自底向上的方法。通过使用强化学习算法来指导学习过程,神经网络也经常用于成功地将机器人传感器映射到输出。

通常,遗传算法将评估大量的个体,以为下一代寻找最佳个体。评估个人是通过运行适应度函数来完成的,该函数基于某些预定义的标准来衡量个人的表现。然而,将遗传算法及其适应度函数应用于物理机器人会带来新的挑战;对每个机器人控制器进行物理评估对于大量人群来说是不可行的。这是由于在物理上测试每个机器人控制器的困难以及这样做所花费的时间。出于这个原因,机器人控制器通常通过将它们应用于真实的物理机器人和环境的模拟模型来进行评估。这使得能够在软件中快速评估每个控制器,随后可以应用于它们的物理对应物。在这一章中,我们将使用二进制遗传算法的知识来设计一个机器人控制器,并开始将其应用于虚拟环境中的虚拟机器人。

问题

我们要解决的问题是设计一个机器人控制器,它可以使用机器人传感器来成功地引导机器人通过迷宫。机器人可以采取四种行动:向前移动一步,左转,右转,或者,很少,什么也不做。机器人也有六个传感器:三个在前面,一个在左边,一个在右边,一个在后面。

A978-1-4842-0328-6_3_Figa_HTML.jpg

我们要探索的迷宫由机器人无法穿越的墙壁组成,并且将有一条轮廓分明的路线,如图 3-1 所示,我们希望机器人沿着这条路线前进。请记住,本章的目的不是训练机器人解决迷宫。我们的目的是给一个有六个传感器的机器人控制器自动编程,使它不会撞到墙上;我们只是用迷宫作为一个复杂的环境来测试我们的机器人控制器。

A978-1-4842-0328-6_3_Fig1_HTML.jpg

图 3-1。

The route we want the robot to follow

机器人的传感器将在检测到传感器附近的墙壁时激活。例如,如果机器人的前传感器检测到机器人前面有墙,它就会激活。

履行

开始之前

本章将基于你在第二章中开发的代码。在开始之前,创建一个新的 Eclipse 或 NetBeans 项目,或者在现有项目中为这本书创建一个名为“第三章的新包。

从第二章的中复制个体、群体和遗传算法类,并将它们导入到第三章的中。确保更新每个类文件顶部的包名!最上面应该都写着“包章 3 ”。

在本章中,除了将包名改为“chapter 3 ”之外,您根本不需要修改个体和群体类。

但是,您将修改 GeneticAlgorithm 类中的几个方法。此时,您应该完全删除以下五个方法:calcFitness、evalPopulation、isTerminationConditionMet、selectParent 和 crossoverPopulation。你将在本章中重写这五个方法,现在删除它们将有助于确保你不会意外地重用第二章的实现。

本章你还将创建一些额外的类(Robot 和 Maze,以及包含程序主要方法的 executive RobotController 类)。如果你在 Eclipse 中工作,通过文件➤新➤类菜单选项创建一个新类是很容易的。注意包名字段,确保它显示“第章第 3 ”。

编码

以正确的方式对数据进行编码通常是遗传算法中最棘手的部分。让我们首先定义这个问题:我们需要一个机器人控制器的完整指令集的二进制表示,用于所有可能的输入组合。

如前所述,我们的机器人会有四个动作:什么都不做,向前走一步,左转,右转。这些可以用二进制表示为:

  • “00”:什么都不做
  • “01”:前进
  • “10”:向左转
  • “11”:向右转

我们还有六个不同的开/关传感器,为我们提供了 2 6 (64)种可能的传感器输入组合。如果每个动作需要 2 位编码,我们可以用 128 位表示控制器对任何可能输入的响应。换句话说,我们有 64 个不同的场景,我们的机器人可以找到自己,我们的控制器需要为每个场景定义一个动作。因为一个动作需要两位,所以我们的控制器需要 64*2 = 128 位的存储空间。

因为遗传算法染色体最容易作为数组来操作,所以我们的染色体将是一个长度为 128 的位数组。在这种情况下,使用我们的变异和交叉方法,你不需要担心他们正在修改哪个特定的指令,他们只需要操纵遗传代码。然而,在我们这边,在我们可以在机器人控制器中使用编码数据之前,我们必须对其进行解包。

假设我们需要 128 位来表示 64 种不同传感器组合的指令,那么我们实际上应该如何构建染色体以便打包和解包呢?也就是说,染色体的每一段对应哪种传感器输入的组合?这些动作的顺序是什么?我们在哪里可以找到染色体内“正面和右前传感器被激活”情况的动作?染色体中的比特代表输出,但是输入是如何表示的呢?

对许多人来说,这将是一个不直观的问题(和解决方案),所以让我们一步一步地解决这个问题。第一步可能是考虑一个简单的、人类可读的输入和输出列表:

Sensor #1 (front): on

Sensor #2 (front-left): off

Sensor #3 (front-right): on

Sensor #4 (left): off

Sensor #5 (right): off

Sensor #6 (back): off

指令:向左转(如上定义的动作“10”)

由于需要额外的 63 个条目来表示所有可能的组合,这种格式很难使用。很明显,这种类型的枚举对我们不起作用。让我们再向前迈一小步,把所有东西都缩写,把“开”和“关”翻译成 1 和 0:

#1: 1

#2: 0

#3: 1

#4: 0

#5: 0

#6: 0

Instruction: 10

我们正在取得进展,但这仍然不能将 64 条指令打包到 128 位数组中。我们的下一步是获取六个传感器值——输入——并进一步编码。让我们从右到左排列它们,并从输出中去掉单词“Instruction ”:

#6:0, #5:0, #4:0, #3:1, #2:0, #1:1 => 10

现在让我们去掉传感器的编号:

000101 => 10

如果我们现在将传感器值的位串转换为十进制,我们会得到以下结果:

5 => 10

现在我们有所发现了。左手边的“5”代表传感器输入,右手边的“10”代表机器人在面对这些输入(输出)时应该做什么。因为我们从传感器输入的二进制表示得到这里,只有一种传感器组合可以给我们数字 5。

我们可以使用数字 5 作为染色体中代表传感器输入组合的位置。如果我们手工构建这个染色体,并且我们知道“10”(向左转)是对“5”(检测墙壁的前部和右前部传感器)的正确响应,我们会将“1”和“0”放置在染色体中的第 11 个和第 12 个(每个动作需要 2 位,我们从 0 开始计算位置),如下所示:

xx xx xx xx xx 10 xx xx xx xx (... 54 more pairs...)

在上面的假染色体中,第一对(位置 0)表示当传感器输入总数为 0 时要采取的动作:关闭一切。第二对(位置 1)表示当传感器输入总计 1 时采取的动作:只有前传感器检测到墙壁。第三对,位置 2,仅代表左前传感器触发。第四对,位置 3,表示前传感器和左前传感器都处于活动状态。依此类推,直到最后一对,位置 63,它代表所有被触发的传感器。

图 3-2 显示了这种编码方案的另一种可视化。最左边的“Sensors”列表示传感器的位域,在将位域转换为十进制后,它映射到一个染色体位置。一旦将传感器的位域转换为十进制,就可以将所需的动作放在染色体的相应位置。

A978-1-4842-0328-6_3_Fig2_HTML.jpg

图 3-2。

Mapping the sensor values to actions

这种编码方案初看起来可能很迟钝——而且染色体是不可读的——但它有几个有用的特性。首先,染色体可以作为一个位数组来操作,而不是复杂的树结构或散列表,这使得交叉、变异和其他操作更加容易。其次,每一个 128 位的值都是一个有效的解决方案(尽管不一定是一个好的方案)——在本章的后面会有更多的介绍。

图 3-2 描述了典型的染色体如何将机器人的传感器值映射到动作。

初始化

在这个实现中,我们首先需要创建并初始化一个迷宫来运行机器人。为此,创建以下迷宫类来管理迷宫。这可以通过下面的代码来完成。通过选择文件➤新➤类,在 Eclipse 中创建一个新类,并确保使用正确的包名,特别是如果您已经从第二章中复制了文件。

package chapter3;

import java.util.ArrayList;

public class Maze {

private final int maze[][];

private int startPosition[] = { -1, -1 };

public Maze(int maze[][]) {

this.maze = maze;

}

public int[] getStartPosition() {

// Check if we’ve already found start position

if (this.startPosition[0] != -1 && this.startPosition[1] != -1) {

return this.startPosition;

}

// Default return value

int startPosition[] = { 0, 0 };

// Loop over rows

for (int rowIndex = 0; rowIndex < this.maze.length; rowIndex++) {

// Loop over columns

for (int colIndex = 0; colIndex < this.maze[rowIndex].length; colIndex++) {

// 2 is the type for start position

if (this.maze[rowIndex][colIndex] == 2) {

this.startPosition = new int[] { colIndex, rowIndex };

return new int[] { colIndex, rowIndex };

}

}

}

return startPosition;

}

public int getPositionValue(int x, int y) {

if (x < 0 || y < 0 || x >= this.maze.length || y >= this.maze[0].length) {

return 1;

}

return this.maze[y][x];

}

public boolean isWall(int x, int y) {

return (this.getPositionValue(x, y) == 1);

}

public int getMaxX() {

return this.maze[0].length - 1;

}

public int getMaxY() {

return this.maze.length - 1;

}

public int scoreRoute(ArrayList<int[]> route) {

int score = 0;

boolean visited[][] = new boolean[this.getMaxY() + 1][this.getMaxX() + 1];

// Loop over route and score each move

for (Object routeStep : route) {

int step[] = (int[]) routeStep;

if (this.maze[step[1]][step[0]] == 3 && visited[step[1]][step[0]] == false) {

// Increase score for correct move

score++;

// Remove reward

visited[step[1]][step[0]] = true;

}

}

return score;

}

}

这段代码包含一个构造函数,用于从一个 double int 数组创建一个新的迷宫,还包含一些公共方法,用于获取起始位置、检查位置的值以及为迷宫中的路线打分。

scoreRoute 方法是迷宫课程中最重要的方法;它评估机器人走的路线,并根据它踩对的瓷砖数量返回一个健康分数。这个 scoreRoute 方法返回的分数就是我们稍后将在 GeneticAlgorithm 类的 calcFitness 方法中用作个体的适应性分数。

现在我们有了迷宫抽象,我们可以创建我们的执行类——实际执行算法的类——并初始化迷宫,如图 3-1 所示。创建另一个名为 RobotController 的新类,并创建程序将从中启动的“main”方法。

package chapter3;

public class RobotController {

public static int maxGenerations = 1000;

public static void main(String[] args) {

/**

* 0 = Empty

* 1 = Wall

* 2 = Starting position

* 3 = Route

* 4 = Goal position

*/

Maze maze = new Maze(new int[][] {

{ 0, 0, 0, 0, 1, 0, 1, 3, 2 },

{ 1, 0, 1, 1, 1, 0, 1, 3, 1 },

{ 1, 0, 0, 1, 3, 3, 3, 3, 1 },

{ 3, 3, 3, 1, 3, 1, 1, 0, 1 },

{ 3, 1, 3, 3, 3, 1, 1, 0, 0 },

{ 3, 3, 1, 1, 1, 1, 0, 1, 1 },

{ 1, 3, 0, 1, 3, 3, 3, 3, 3 },

{ 0, 3, 1, 1, 3, 1, 0, 1, 3 },

{ 1, 3, 3, 3, 3, 1, 1, 1, 4 }

});

/**

* We’ll implement the genetic algorithm pseudocode

* from chapter``2

*/

}

}

我们创建的迷宫对象使用整数来表示不同的地形类型:1 定义一堵墙;2 是起始位置,3 是通过迷宫的最佳路线,4 是目标位置,0 是机器人可以越过但不在通往目标的路线上的空位置。

接下来,与前面的实现类似,我们需要初始化一个随机个体群体。这些个体中的每一个都应该有 128 的染色体长度。如前所述,128 位允许我们将所有 64 个输入映射到一个动作。由于不可能为这个问题创建无效的染色体,我们可以像以前一样使用相同的随机初始化——回想一下,这个随机初始化发生在单个类构造函数中,我们从第二章中复制了未修改的类构造函数。以这种方式初始化的机器人在面对不同情况时会简单地采取随机行动,通过一代又一代的进化,我们希望改进这种行为。

在我们的主方法中删除第二章中常见的遗传算法伪代码之前,我们应该对从第二章的中复制的遗传算法类做一个修改。我们将在 GeneticAlgorithm 类和构造函数中添加一个名为“tournamentSize”的属性(我们将在本章后面深入讨论)。

修改 GeneticAlgorithm 类的顶部,如下所示:

package chapter3;

public class GeneticAlgorithm {

/**

* See chapter``2

*/

private int populationSize;

private double mutationRate;

private double crossoverRate;

private int elitismCount;

/**

* A new property we’ve introduced is the size of the population used for

* tournament selection in crossover.

*/

protected int tournamentSize;

public GeneticAlgorithm(int populationSize, double mutationRate, double crossoverRate, int elitismCount,

int tournamentSize) {

this.populationSize = populationSize;

this.mutationRate = mutationRate;

this.crossoverRate = crossoverRate;

this.elitismCount = elitismCount;

this.tournamentSize = tournamentSize;

}

/**

* We’re not going to show the rest of the class here,

* but methods like initPopulation, mutatePopulation,

* and evaluatePopulation should appear below.

*/

}

我们做了三个简单的更改:首先,我们在类属性中添加了“protected int tournamentSize”。其次,我们添加了“int tournamentSize”作为构造函数的第五个参数。最后,我们将“this . tournamentSize = tournamentSize”赋值添加到构造函数中。

处理了 tournamentSize 属性后,我们可以继续前进,从第二章中删除我们的伪代码。和往常一样,这段代码将放在 executive 类的“main”方法中,在本例中我们将其命名为 RobotController。

当然,下面的代码不会做任何事情——我们还没有实现任何我们需要的方法,并且已经用 TODO 注释替换了所有的内容。但是,以这种方式剔除主方法有助于加强遗传算法的概念执行模型,也有助于我们在仍然需要实现的方法方面保持正轨;此类中有七个 TODOs 需要解决。

更新您的 RobotController 类,如下所示。迷宫的定义和以前一样,但是它下面的所有内容都是这个文件的新内容。

package chapter3;

public class RobotController {

public static int maxGenerations = 1000;

public static void main(String[] args) {

Maze maze = new Maze(new int[][] {

{ 0, 0, 0, 0, 1, 0, 1, 3, 2 },

{ 1, 0, 1, 1, 1, 0, 1, 3, 1 },

{ 1, 0, 0, 1, 3, 3, 3, 3, 1 },

{ 3, 3, 3, 1, 3, 1, 1, 0, 1 },

{ 3, 1, 3, 3, 3, 1, 1, 0, 0 },

{ 3, 3, 1, 1, 1, 1, 0, 1, 1 },

{ 1, 3, 0, 1, 3, 3, 3, 3, 3 },

{ 0, 3, 1, 1, 3, 1, 0, 1, 3 },

{ 1, 3, 3, 3, 3, 1, 1, 1, 4 }

});

// Create genetic algorithm

GeneticAlgorithm ga = new GeneticAlgorithm(200, 0.05, 0.9, 2, 10);

Population population = ga.initPopulation(128);

// TODO: Evaluate population

int generation = 1;

// Start evolution loop

while (/* TODO */ false) {

// TODO: Print fittest individual from population

// TODO: Apply crossover

// TODO: Apply mutation

// TODO: Evaluate population

// Increment the current generation

generation++;

}

// TODO: Print results

}

}

估价

在评估阶段,我们需要定义一个适应度函数来评估每个机器人控制器。我们可以通过增加个体对路线上每个正确的独特移动的适应度来做到这一点。回想一下,我们之前创建的迷宫类有一个 scoreRoute 方法来执行这个评估。然而,路线本身来自于自主控制下的机器人。因此,在我们可以给迷宫类一条路线进行评估之前,我们需要创建一个可以遵循指令并通过执行这些指令来生成路线的机器人。

创建一个机器人类来管理机器人的功能。在 Eclipse 中,您可以通过选择菜单选项 File ➤新➤类来创建一个新类。确保使用正确的包名。将以下代码添加到文件中:

package chapter3;

import java.util.ArrayList;

/**

* A robot abstraction. Give it a maze and an instruction set, and it will

* attempt to navigate to the finish.

*

* @author bkanber

*

*/

public class Robot {

private enum Direction {NORTH, EAST, SOUTH, WEST};

private int xPosition;

private int yPosition;

private Direction heading;

int maxMoves;

int moves;

private int sensorVal;

private final int sensorActions[];

private Maze maze;

private ArrayList<int[]> route;

/**

* Initalize a robot with controller

*

* @param sensorActions The string to map the sensor value to actions

* @param maze The maze the robot will use

* @param maxMoves The maximum number of moves the robot can make

*/

public Robot(int[] sensorActions, Maze maze, int maxMoves){

this.sensorActions = this.calcSensorActions(sensorActions);

this.maze = maze;

int startPos[] = this.maze.getStartPosition();

this.xPosition = startPos[0];

this.yPosition = startPos[1];

this.sensorVal = -1;

this.heading = Direction.EAST;

this.maxMoves = maxMoves;

this.moves = 0;

this.route = new ArrayList<int[]>();

this.route.add(startPos);

}

/**

* Runs the robot’s actions based on sensor inputs

*/

public void run(){

while(true){

this.moves++;

// Break if the robot stops moving

if (this.getNextAction() == 0) {

return;

}

// Break if we reach the goal

if (this.maze.getPositionValue(this.xPosition, this.yPosition) == 4) {

return;

}

// Break if we reach a maximum number of moves

if (this.moves > this.maxMoves) {

return;

}

// Run action

this.makeNextAction();

}

}

/**

* Map robot’s sensor data to actions from binary string

*

* @param sensorActionsStr Binary GA chromosome

* @return int[] An array to map sensor value to an action

*/

private int[] calcSensorActions(int[] sensorActionsStr){

// How many actions are there?

int numActions = (int) sensorActionsStr.length / 2;

int sensorActions[] = new int[numActions];

// Loop through actions

for (int sensorValue = 0; sensorValue < numActions; sensorValue++){

// Get sensor action

int sensorAction = 0;

if (sensorActionsStr[sensorValue*2] == 1){

sensorAction += 2;

}

if (sensorActionsStr[(sensorValue*2)+1] == 1){

sensorAction += 1;

}

// Add to sensor-action map

sensorActions[sensorValue] = sensorAction;

}

return sensorActions;

}

/**

* Runs the next action

*/

public void makeNextAction(){

// If move forward

if (this.getNextAction() == 1) {

int currentX = this.xPosition;

int currentY = this.yPosition;

// Move depending on current direction

if (Direction.NORTH == this.heading) {

this.yPosition += -1;

if (this.yPosition < 0) {

this.yPosition = 0;

}

}

else if (Direction.EAST == this.heading) {

this.xPosition += 1;

if (this.xPosition > this.maze.getMaxX()) {

this.xPosition = this.maze.getMaxX();

}

}

else if (Direction.SOUTH == this.heading) {

this.yPosition += 1;

if (this.yPosition > this.maze.getMaxY()) {

this.yPosition = this.maze.getMaxY();

}

}

else if (Direction.WEST == this.heading) {

this.xPosition += -1;

if (this.xPosition < 0) {

this.xPosition = 0;

}

}

// We can’t move here

if (this.maze.isWall(this.xPosition, this.yPosition) == true) {

this.xPosition = currentX;

this.yPosition = currentY;

}

else {

if(currentX != this.xPosition || currentY != this.yPosition) {

this.route.add(this.getPosition());

}

}

}

// Move clockwise

else if(this.getNextAction() == 2) {

if (Direction.NORTH == this.heading) {

this.heading = Direction.EAST;

}

else if (Direction.EAST == this.heading) {

this.heading = Direction.SOUTH;

}

else if (Direction.SOUTH == this.heading) {

this.heading = Direction.WEST;

}

else if (Direction.WEST == this.heading) {

this.heading = Direction.NORTH;

}

}

// Move anti-clockwise

else if(this.getNextAction() == 3) {

if (Direction.NORTH == this.heading) {

this.heading = Direction.WEST;

}

else if (Direction.EAST == this.heading) {

this.heading = Direction.NORTH;

}

else if (Direction.SOUTH == this.heading) {

this.heading = Direction.EAST;

}

else if (Direction.WEST == this.heading) {

this.heading = Direction.SOUTH;

}

}

// Reset sensor value

this.sensorVal = -1;

}

/**

* Get next action depending on sensor mapping

*

* @return int Next action

*/

public int getNextAction() {

return this.sensorActions[this.getSensorValue()];

}

/**

* Get sensor value

*

* @return int Next sensor value

*/

public int getSensorValue(){

// If sensor value has already been calculated

if (this.sensorVal > -1) {

return this.sensorVal;

}

boolean frontSensor, frontLeftSensor, frontRightSensor, leftSensor, rightSensor, backSensor;

frontSensor = frontLeftSensor = frontRightSensor = leftSensor = rightSensor = backSensor = false;

// Find which sensors have been activated

if (this.getHeading() == Direction.NORTH) {

frontSensor = this.maze.isWall(this.xPosition, this.yPosition-1);

frontLeftSensor = this.maze.isWall(this.xPosition-1, this.yPosition-1);

frontRightSensor = this.maze.isWall(this.xPosition+1, this.yPosition-1);

leftSensor = this.maze.isWall(this.xPosition-1, this.yPosition);

rightSensor = this.maze.isWall(this.xPosition+1, this.yPosition);

backSensor = this.maze.isWall(this.xPosition, this.yPosition+1);

}

else if (this.getHeading() == Direction.EAST) {

frontSensor = this.maze.isWall(this.xPosition+1, this.yPosition);

frontLeftSensor = this.maze.isWall(this.xPosition+1, this.yPosition-1);

frontRightSensor = this.maze.isWall(this.xPosition+1, this.yPosition+1);

leftSensor = this.maze.isWall(this.xPosition, this.yPosition-1);

rightSensor = this.maze.isWall(this.xPosition, this.yPosition+1);

backSensor = this.maze.isWall(this.xPosition-1, this.yPosition);

}

else if (this.getHeading() == Direction.SOUTH) {

frontSensor = this.maze.isWall(this.xPosition, this.yPosition+1);

frontLeftSensor = this.maze.isWall(this.xPosition+1, this.yPosition+1);

frontRightSensor = this.maze.isWall(this.xPosition-1, this.yPosition+1);

leftSensor = this.maze.isWall(this.xPosition+1, this.yPosition);

rightSensor = this.maze.isWall(this.xPosition-1, this.yPosition);

backSensor = this.maze.isWall(this.xPosition, this.yPosition-1);

}

else {

frontSensor = this.maze.isWall(this.xPosition-1, this.yPosition);

frontLeftSensor = this.maze.isWall(this.xPosition-1, this.yPosition+1);

frontRightSensor = this.maze.isWall(this.xPosition-1, this.yPosition-1);

leftSensor = this.maze.isWall(this.xPosition, this.yPosition+1);

rightSensor = this.maze.isWall(this.xPosition, this.yPosition-1);

backSensor = this.maze.isWall(this.xPosition+1, this.yPosition);

}

// Calculate sensor value

int sensorVal = 0;

if (frontSensor == true) {

sensorVal += 1;

}

if (frontLeftSensor == true) {

sensorVal += 2;

}

if (frontRightSensor == true) {

sensorVal += 4;

}

if (leftSensor == true) {

sensorVal += 8;

}

if (rightSensor == true) {

sensorVal += 16;

}

if (backSensor == true) {

sensorVal += 32;

}

this.sensorVal = sensorVal;

return sensorVal;

}

/**

* Get robot’s position

*

* @return int[] Array with robot’s position

*/

public int[] getPosition(){

return new int[]{this.xPosition, this.yPosition};

}

/**

* Get robot’s heading

*

* @return Direction Robot’s heading

*/

private Direction getHeading(){

return this.heading;

}

/**

* Returns robot’s complete route around the maze

*

* @return ArrayList<int> Robot’s route

*/

public ArrayList<int[]> getRoute(){

return this.route;

}

/**

* Returns route in printable format

*

* @return String Robot’s route

*/

public String printRoute(){

String route = "";

for (Object routeStep : this.route) {

int step[] = (int[]) routeStep;

route += "{" + step[0] + "," + step[1] + "}";

}

return route;

}

}

这个类包含创建新机器人的构造函数。它还包含读取机器人传感器的功能,以获得机器人的方向,并在迷宫中移动机器人。这个机器人类是我们模拟一个简单机器人的方式,这样我们就不必在 100 个实际机器人上运行 1000 代进化。在这样的优化问题中,你经常会发现像 Maze 和 Robot 这样的类,在生产硬件中优化你的结果之前,通过软件进行模拟是有成本效益的。

回想一下,从技术上来说,是迷宫类评估了一条路线的适合度。然而,我们仍然需要在 GeneticAlgorithm 类中实现 calcFitness 方法。calcFitness 方法不是直接计算适应性分数,而是通过用个体的染色体(即,传感器控制器指令集)创建新的机器人并对照我们的迷宫对其进行评估,来负责将个体、机器人和迷宫类联系在一起。

在 GeneticAlgorithm 类中编写以下 calcFitness 函数。和往常一样,这个方法可以放在类中的任何地方。

public double calcFitness(Individual individual, Maze maze) {

int[] chromosome = individual.getChromosome();

Robot robot = new Robot(chromosome, maze, 100);

robot.run();

int fitness = maze.scoreRoute(robot.getRoute());

individual.setFitness(fitness);

return fitness;

}

在这里,calcFitness 方法接受两个参数,individual 和 maze,它使用这两个参数来创建一个新的机器人,并让它穿过迷宫。机器人的路线然后被评分并存储为个体的适应度。

这段代码将创建一个机器人,把它放在我们的迷宫中,并用进化的控制器测试它。机器人构造器的最后一个参数是允许机器人移动的最大次数。这将防止它陷入死胡同,或在永无止境的圈子里转来转去。然后,我们可以简单地获得机器人路线的分数,并使用 Maze 的 scoreRoute 方法将其作为适应度返回。

有了一个有效的 calcFitness 方法,我们现在可以创建一个 evalPopulation 方法。回想一下第二章中的内容,evalPopulation 方法只是简单地对群体中的每个个体进行循环,并为该个体调用 calcFitness,对整个群体的适应性进行求和。事实上,这一章的 evalPopulation 几乎等同于第二章的——但在这种情况下,我们还需要将迷宫对象传递给 calcFitness 方法,所以我们需要稍微修改一下。

将以下方法添加到 GeneticAlgorithm 类中的任意位置:

public void evalPopulation(Population population, Maze maze) {

double populationFitness = 0;

for (Individual individual : population.getIndividuals()) {

populationFitness += this.calcFitness(individual, maze);

}

population.setPopulationFitness(populationFitness);

}

这个版本和第二章的版本唯一的区别就是包含了“Maze maze”作为第二个参数,同时也将“Maze”作为第二个参数传递给 calcFitness。

此时,您可以解析 RobotController 的“main”方法中的两行“TODO: Evaluate population”。找到显示以下内容的两个位置:

// TODO: Evaluate population

并替换为:

// Evaluate population

ga.evalPopulation(population, maze);

与第二章不同,该方法需要将迷宫对象作为第二个参数传递。此时,RobotController 的 main 方法中应该只剩下五个“TODO”注释。在下一节中,我们将很快介绍其中的三个。这就是进步!

终止检查

我们将在这个实现中使用的终止检查与我们以前的遗传算法中使用的略有不同。这里,我们将在经过最大数量的代之后终止。

若要添加此终止检查,首先要将以下 isTerminationConditionMet 方法添加到 GeneticAlgorithm 类中。

public boolean isTerminationConditionMet(int generationsCount, int maxGenerations) {

return (generationsCount > maxGenerations);

}

该方法只接受当前代计数器和允许的最大代,并根据算法是否应该终止返回 true 或 false。事实上,这足够简单,我们可以直接在遗传算法循环的“while”条件中使用该逻辑——然而,为了保持一致性,我们将始终将终止条件检查作为 genetic algorithm 类中的一个方法来实现,即使它是一个像上面这样的普通方法。

现在,我们可以通过向 RobotController 的 main 方法添加以下代码,将我们的终止检查应用于进化循环。我们简单地将代数和最大代数作为参数传递。

通过将终止条件添加到“while”语句中,您实际上是在使循环起作用,因此我们也应该借此机会打印出一些统计信息和调试信息。

下面的更改很简单:首先,更新“while”条件以使用 ga.isTerminationConditionMet。其次,在循环中和循环之后添加对 population.getFittest 和 System.out.println 的调用,以便显示进度和结果。

这是 RobotController 类此时应该的样子;我们刚刚淘汰了三个 TODOs,只剩下两个:

package chapter3;

public class RobotController {

public static int maxGenerations = 1000;

public static void main(String[] args) {

Maze maze = new Maze(new int[][] {

{ 0, 0, 0, 0, 1, 0, 1, 3, 2 },

{ 1, 0, 1, 1, 1, 0, 1, 3, 1 },

{ 1, 0, 0, 1, 3, 3, 3, 3, 1 },

{ 3, 3, 3, 1, 3, 1, 1, 0, 1 },

{ 3, 1, 3, 3, 3, 1, 1, 0, 0 },

{ 3, 3, 1, 1, 1, 1, 0, 1, 1 },

{ 1, 3, 0, 1, 3, 3, 3, 3, 3 },

{ 0, 3, 1, 1, 3, 1, 0, 1, 3 },

{ 1, 3, 3, 3, 3, 1, 1, 1, 4 }

});

// Create genetic algorithm

GeneticAlgorithm ga = new GeneticAlgorithm(200, 0.05, 0.9, 2, 10);

Population population = ga.initPopulation(128);

// Evaluate population

ga.evalPopulation(population, maze);

int generation = 1;

// Start evolution loop

while (ga.isTerminationConditionMet(generation, maxGenerations) == false) {

// Print fittest individual from population

Individual fittest = population.getFittest(0);

System.out.println("G" + generation + " Best solution (" + fittest.getFitness() + "): " + fittest.toString());

// TODO: Apply crossover

// TODO: Apply mutation

// Evaluate population

ga.evalPopulation(population, maze);

// Increment the current generation

generation++;

}

System.out.println("Stopped after " + maxGenerations + " generations.");

Individual fittest = population.getFittest(0);

System.out.println("Best solution (" + fittest.getFitness() + "): " + fittest.toString());

}

}

如果你现在点击运行按钮,你会看到算法快速循环通过 1000 代(没有实际的进化!)并自豪地向您展示一个非常非常糟糕的解决方案,从统计学上来说,最有可能是 1.0。

这并不奇怪;我们仍然没有实现交叉或变异!正如你在第二章中所学的,你至少需要其中一种机制来推动进化,但是一般来说,为了避免陷入局部最优,你需要两种机制。

上面的 main 方法中还有两个 TODOs,幸运的是,我们可以很快解决其中一个。我们在第二章中学到的突变技术——比特翻转突变——对这个问题也有效。

当评估变异或交叉算法的可行性时,您必须首先考虑什么是有效染色体的约束。在这种情况下,对于这个特定的问题,一个有效的染色体只有两个约束:必须是二进制的,长度必须是 128 位。只要满足这两个约束,就没有被视为无效的位组合或位序列。因此,我们能够重用第二章中的简单突变方法。

启用突变很简单,与上一章相同。更新“TODO: Mutate population”行以反映以下内容:

// Apply mutation

population = ga.mutatePopulation(population);

请尝试在此时再次运行该程序。结果并不引人注目;1000 代后你可能会得到 5 分或者 10 分的适应度。然而,有一件事是清楚的:种群正在进化,我们离终点越来越近了。

我们只剩下一件事要做:交叉。

选择方法和交叉

在我们以前的遗传算法中,我们使用轮盘赌选择来选择父代进行统一的交叉操作。回想一下,杂交是一种用于结合双亲遗传信息的技术。在这个实现中,我们将使用一种称为锦标赛选择的新选择方法和一种称为单点交叉的新交叉方法。

锦标赛选择

像轮盘赌选择一样,锦标赛选择提供了一种基于个体的健康值来选择个体的方法。也就是说,个体的适应度越高,该个体被选择进行交叉的机会就越大。

锦标赛选择通过运行一系列“锦标赛”来选择其父项。首先,从人群中随机选择个体并参加比赛。接下来,这些个体可以被认为是通过比较它们的适应值来彼此竞争,然后为父代选择具有最高适应值的个体。

锦标赛选择需要定义锦标赛规模,指定应该从人群中挑选多少人参加锦标赛。与大多数参数一样,根据所选的值,性能会有所折衷。高锦标赛规模会考虑更大比例的人口。这使得更有可能在群体中找到得分较高的个体。另一方面,由于竞争较少,低锦标赛规模将从群体中更随机地选择个体,结果通常选择排名较低的个体。高比赛规模可能导致遗传多样性的损失,其中只有最好的个体被选择作为亲本。相反,由于减少了选择压力,低锦标赛规模会减慢算法的进度。

锦标赛选择是遗传算法中最常用的选择方法之一。它的优点是实现起来相对简单,并且允许通过更新锦标赛规模来改变选择压力。然而,它也有局限性。考虑一下得分最低的个人何时进入锦标赛。人口中的其他个体被添加到锦标赛中并不重要,它永远不会被选择,因为其他个体被保证具有更高的适应值。这个缺点可以通过给算法增加一个选择概率来解决。例如,如果选择概率设置为 0.6,则有 60%的机会选择最适合的个体。如果最适合的个体没有被选中,那么它将继续移动到第二个最适合的个体,以此类推,直到一个个体被选中。虽然这种修改允许偶尔选择甚至排名最差的个体,但它没有考虑个体之间的适合度差异。例如,如果有三个人被选择参加锦标赛,一个人的健康值为 9,一个人的健康值为 2,另一个人的健康值为 1。在这种情况下,如果适合度值为 8,那么适合度值为 2 的个体不太可能被选中。这意味着有时个人被给予不合理的高或低的选择几率。

我们不会在锦标赛选择实现中实现选择概率;然而,对于读者来说,这是一个极好的练习。

要实现锦标赛选择,请将以下代码添加到 GeneticAlgorithm 类中的任意位置:

public Individual selectParent(Population population) {

// Create tournament

Population tournament = new Population(this.tournamentSize);

// Add random individuals to the tournament

population.shuffle();

for (int i = 0; i < this.tournamentSize; i++) {

Individual tournamentIndividual = population.getIndividual(i);

tournament.setIndividual(i, tournamentIndividual);

}

// Return the best

return tournament.getFittest(0);

}

首先,我们创建一个新的群体来容纳选择锦标赛中的所有个体。接下来,个体被随机添加到群体中,直到其大小等于锦标赛大小参数。最后,从锦标赛群体中选出最佳个体并返回。

单点交叉

单点交叉是我们之前实现的均匀交叉方法的替代交叉方法。单点杂交是一种非常简单的杂交方法,随机选择基因组中的一个位置来定义哪些基因来自哪个亲本。交叉位置之前的遗传信息来自 parent1,而该位置之后的遗传信息来自 parent2。

A978-1-4842-0328-6_3_Figb_HTML.jpg

单点交叉比较容易实现,并且与均匀交叉相比,单点交叉可以更有效地从父节点传输连续的位组。这是交叉算法的一个有价值的特性。考虑我们的具体问题,其中染色体是基于六个传感器输入的一组编码指令,每个指令的长度超过一位。

想象一个理想的交叉情况如下:parent1 在前 32 次传感器操作中表现出色,parent2 在最后 16 次操作中表现出色。如果我们使用第二章中的统一交叉技术,我们会得到到处都是混乱的比特!由于均匀交叉随机选择位进行交换,单个指令将在交叉中被改变和破坏。两位指令可能根本不会被保留,因为每条指令的两位中的一位可能会被修改。然而,单点交叉让我们能够利用这种理想的情况。如果交叉点直接位于染色体的中间,那么后代将以 64 个不间断的位结束,代表来自父代 1 的 32 条指令,以及来自父代 2 的 16 条指令。因此,后代现在在 64 种可能状态中的 48 种上表现出色。这个概念是遗传算法的基础:后代可能比父母任何一方都强,因为它吸取了双方的最佳品质。

然而,单点交叉并非没有局限性。单点杂交的一个局限是父母基因组的某些组合是不可能的。例如,考虑两个父母:一个基因组为“00100”,另一个基因组为“10001”。孩子“10101”不可能单独通过杂交产生,尽管所需的基因在双亲中都存在。幸运的是,我们也有突变作为进化机制,如果交叉和突变都实施,基因组“10101”是可能的。

单点交叉的另一个限制是,向左的基因偏向于来自父代 1,向右的基因偏向于来自父代 2。为了解决这个问题,可以实现两点交叉,其中使用两个位置,允许分区跨越父代基因组的边缘。我们将两点交叉留给读者作为练习。

A978-1-4842-0328-6_3_Figc_HTML.jpg

若要实现单点交叉,请将以下代码添加到 GeneticAlgorithm 类中。这个 crossoverPopulation 方法依赖于上面实现的 selectParent 方法,因此使用锦标赛选择。请注意,没有要求使用单点交叉的锦标赛选择;您可以使用 selectParent 的任何实现,但是对于这个问题,我们选择了锦标赛选择和单点交叉,因为它们都是非常常见且需要理解的重要概念。

public Population crossoverPopulation(Population population) {

// Create new population

Population newPopulation = new Population(population.size());

// Loop over current population by fitness

for (int populationIndex = 0; populationIndex < population.size(); populationIndex++) {

Individual parent1 = population.getFittest(populationIndex);

// Apply crossover to this individual?

if (this.crossoverRate > Math.random() && populationIndex >= this.elitismCount) {

// Initialize offspring

Individual offspring = new Individual(parent1.getChromosomeLength());

// Find second parent

Individual parent2 = this.selectParent(population);

// Get random swap point

int swapPoint = (int) (Math.random() * (parent1.getChromosomeLength() + 1));

// Loop over genome

for (int geneIndex = 0; geneIndex < parent1.getChromosomeLength(); geneIndex++) {

// Use half of parent1's genes and half of parent2's genes

if (geneIndex < swapPoint) {

offspring.setGene(geneIndex, parent1.getGene(geneIndex));

} else {

offspring.setGene(geneIndex, parent2.getGene(geneIndex));

}

}

// Add offspring to new population

newPopulation.setIndividual(populationIndex, offspring);

} else {

// Add individual to new population without applying crossover

newPopulation.setIndividual(populationIndex, parent1);

}

}

return newPopulation;

}

注意,虽然我们在本章中没有提到精英主义,但它仍然出现在上面和变异算法中(与前一章相比没有变化)。

单点杂交之所以受欢迎,既是因为它有利的遗传属性(保留连续基因),也是因为它易于实施。在上面的代码中,为新个体创建了一个新群体。接下来,循环种群,并按照适合度的顺序提取个体。如果精英主义被启用,精英个体将被跳过并直接添加到新群体中,否则将根据交叉率决定是否交叉当前个体。如果该个体被选择进行杂交,则使用锦标赛选择挑选第二个亲本。

接下来,随机选择一个交叉点。在这一点上,我们将停止使用父母 1 的基因,而开始使用父母 2 的基因。然后,我们简单地在染色体上循环,首先将 parent1 的基因添加到后代,然后在交叉点之后切换到 parent2 的基因。

现在我们可以在 RobotController 的 main 方法中调用 crossover。添加行“population = ga . cross over population(population)”解决了我们的最终任务,您应该会得到一个如下所示的 RobotController 类:

package chapter3;

public class RobotController {

public static int maxGenerations = 1000;

public static void main(String[] args) {

Maze maze = new Maze(new int[][] {

{ 0, 0, 0, 0, 1, 0, 1, 3, 2 },

{ 1, 0, 1, 1, 1, 0, 1, 3, 1 },

{ 1, 0, 0, 1, 3, 3, 3, 3, 1 },

{ 3, 3, 3, 1, 3, 1, 1, 0, 1 },

{ 3, 1, 3, 3, 3, 1, 1, 0, 0 },

{ 3, 3, 1, 1, 1, 1, 0, 1, 1 },

{ 1, 3, 0, 1, 3, 3, 3, 3, 3 },

{ 0, 3, 1, 1, 3, 1, 0, 1, 3 },

{ 1, 3, 3, 3, 3, 1, 1, 1, 4 }

});

// Create genetic algorithm

GeneticAlgorithm ga = new GeneticAlgorithm(200, 0.05, 0.9, 2, 10);

Population population = ga.initPopulation(128);

// Evaluate population

ga.evalPopulation(population, maze);

int generation = 1;

// Start evolution loop

while (ga.isTerminationConditionMet(generation, maxGenerations) == false) {

// Print fittest individual from population

Individual fittest = population.getFittest(0);

System.out.println("G" + generation + " Best solution (" + fittest.getFitness() + "): " + fittest.toString());

// Apply crossover

population = ga.crossoverPopulation(population);

// Apply mutation

population = ga.mutatePopulation(population);

// Evaluate population

ga.evalPopulation(population, maze);

// Increment the current generation

generation++;

}

System.out.println("Stopped after " + maxGenerations + " generations.");

Individual fittest = population.getFittest(0);

System.out.println("Best solution (" + fittest.getFitness() + "): " + fittest.toString());

}

}

执行

此时,您的 GeneticAlgorithm 类应该具有以下属性和方法签名:

package chapter3;

public class GeneticAlgorithm {

private int populationSize;

private double mutationRate;

private double crossoverRate;

private int elitismCount;

protected int tournamentSize;

public GeneticAlgorithm(int populationSize, double mutationRate, double crossoverRate, int elitismCount, int tournamentSize) { }

public Population initPopulation(int chromosomeLength) { }

public double calcFitness(Individual individual, Maze maze) { }

public void evalPopulation(Population population, Maze maze) { }

public boolean isTerminationConditionMet(int generationsCount, int maxGenerations) { }

public Individual selectParent(Population population) { }

public Population mutatePopulation(Population population) { }

public Population crossoverPopulation(Population population) { }

}

如果您的方法签名与上面的不匹配,或者如果您不小心遗漏了一个方法,或者如果您的 IDE 显示任何错误,您应该立即返回并解决它们。

否则,单击运行。

你应该会看到 1000 代的进化,希望你的算法以 29 的适应值结束,这是这个特殊迷宫的最大值。(您可以在迷宫定义中计算“路线”瓷砖的数量(用“3”表示)来获得这个数字。

回想一下,这个算法的目的不是解决迷宫,而是对机器人的传感器控制器进行编程。据推测,我们现在可以在执行结束时取出获胜的染色体,并将其编程到一个物理机器人中,并且对传感器控制器将做出适当的动作来导航不仅是这个迷宫,而且是任何迷宫而不会撞到墙壁有很高的信心。不能保证这个机器人会找到通过迷宫的最有效的路线,因为这不是我们训练它做的,但它至少不会崩溃。

虽然 64 个传感器组合对于手工编程来说似乎不是太令人畏惧,但考虑一下同样的问题,但在三维空间中:一架自主飞行的四轴飞行器无人机可能有 20 个传感器,而不是 6 个。在这种情况下,您必须为传感器输入的 2 20 种组合编程,大约一百万种不同的指令。

摘要

遗传算法可用于设计复杂的控制器,这对于人工来说可能是困难的或耗时的。机器人控制器由适应度函数来评估,适应度函数通常会模拟机器人及其环境,以节省时间,因为不需要对机器人进行物理测试。

通过给机器人一个迷宫和一条优选路线,可以应用遗传算法来找到一个控制器,该控制器可以使用机器人的传感器来成功地通过迷宫。这可以通过在个体的染色体编码中给每个传感器分配一个动作来实现。通过交叉和变异进行小的随机变化,在选择过程的指导下,逐渐发现更好的控制器。

锦标赛选择是遗传算法中使用的一种比较流行的选择方法。它的工作原理是从群体中随机挑选预定数量的个体,然后比较所选个体的适应值以找到最佳值。具有最高健康值的个人“赢得”锦标赛,然后作为被选中的个人返回。较大的锦标赛规模会导致较大的选择压力,在选择最佳锦标赛规模时需要仔细考虑这一点。

当一个个体被选择后,它将进行交叉;可以使用的一种交换方法是单点交换。在这种交叉方法中,随机选取染色体中的单个点,然后该点之前的任何遗传信息来自父母 A,该点之后的任何遗传信息来自父母 b。这导致父母遗传信息的合理随机混合,但是通常使用改进的两点交叉方法。在两点交叉中,选择一个起点和一个终点,用它们来选择来自亲本 A 的遗传信息,剩下的遗传信息来自亲本 b。

练习

Add a second termination condition that terminates the algorithm when the route has been fully explored.   Run the algorithm with different tournament sizes. Study how the performance is affected.   Add a selection probability to the tournament selection method. Test with different probability settings. Examine how it affects the genetic algorithm’s performance.   Implement two-point crossover. Does it improve the results?