Java8-遗传算法基础-全-
Java8 遗传算法基础(全)
一、简介
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。正是这种选择性压力慢慢引导进化去寻找越来越适合和更好适应的个体。
生物进化的一个例子
为了帮助阐明这个过程将如何逐渐导致越来越健康的个体的进化,考虑下面的例子:
在一个遥远的星球上,存在着一种形状为白色正方形的物种。
白色的方形物种已经和平地生活了几千年,直到最近一个新的物种到来,黑色的圆形。
黑圈物种是食肉动物,开始以白方种群为食。
白色方块没有任何方法来保护自己免受黑色圆圈的攻击。直到有一天,其中一个幸存的白方随机从一个白方突变成了一个黑方。黑色圆圈不再把新的黑色方块视为食物,因为它和自己是同一个颜色。
一些幸存的广场人口交配,创造了新一代的广场。其中一些新方块继承了黑色方块的颜色基因。
然而,白色方块继续被吃掉…
最终,由于它们看起来与黑圈相似的进化优势,它们不再被吃掉。现在,正方形剩下的唯一颜色是黑色正方形。
不再受黑色圆圈的控制,黑色方块再次自由地生活在和平之中。
基本术语
遗传算法建立在生物进化的概念上,所以如果你熟悉进化中的术语,你可能会注意到在使用遗传算法时术语的重叠。这些领域之间的相似性当然是由于进化算法,更具体地说,遗传算法类似于自然界中发现的过程。
条款
重要的是,在我们深入到遗传算法领域之前,我们首先要理解一些使用的基本语言和术语。随着本书的进展,将根据需要引入更复杂的术语。下面列出了一些比较常见的术语,以供参考。
- 群体——这只是一个候选解决方案的集合,可以应用遗传操作符,如突变和交叉。
- 候选解决方案–给定问题的可能解决方案。
- 基因——构成染色体的不可分割的构件。传统上,一个基因由 0 或 1 组成。
- 染色体——染色体是一串基因。染色体定义了一个特定的候选解。具有二进制编码的典型染色体可能包含类似“01101011”的内容。
- 突变——候选解决方案中的基因被随机改变以创造新性状的过程。
- 交叉——染色体结合产生新的候选解的过程。这有时被称为重组。
- 选择——这是挑选候选解决方案以培育下一代解决方案的技术。
- 适合度–衡量候选解决方案适合给定问题的程度的分数。
搜索空间
在计算机科学中,当处理具有许多需要搜索的候选解的优化问题时,我们将解的集合称为“搜索空间”。搜索空间中的每个特定点都充当给定问题的候选解决方案。在这个搜索空间中,有一个距离的概念,距离较近的解决方案比距离较远的解决方案更有可能表达相似的特征。为了理解这些距离在搜索空间上是如何组织的,考虑下面使用二进制遗传表示的例子:
“101”和“111”只差 1 个。这是因为从“101”转换到“111”只需要 1 次改变(将 0 翻转到 1)。这意味着这些解决方案在搜索空间上仅相隔 1 个空间。
另一方面,“000”与“111”相差三个数量级。这使得它的距离为 3,将“000”放置在搜索空间中距离“111”3 个空格的位置。
因为具有较少变化的解决方案被分组为彼此更接近,所以搜索空间上的解决方案之间的距离可以用于提供另一个解决方案所具有的特性的近似。这种理解经常被许多搜索算法用作改善其搜索结果的策略。
健身景观
当在搜索空间内找到的候选解被标记为它们各自的适合度水平时,我们可以开始把搜索空间看作一个“适合度景观”。图 1-1 提供了一个 2D 健身景观的例子。
图 1-1。
A 2D fitness landscape
在我们的适应度图的底部轴上是我们正在优化的值,在左侧轴上是其相应的适应度值。我应该注意到,这通常是对实践中发现的问题的过度简化。大多数真实世界的应用有多个值,需要优化创建一个多维健身景观。
在上面的例子中,可以看到搜索空间中每个候选解的适应值。这使得很容易看到最适合的解决方案位于何处,然而,为了使这在现实中成为可能,搜索空间中的每个候选解决方案都需要对它们的适合度函数进行评估。对于具有指数搜索空间的复杂问题,评估每个解的适应值是不合理的。在这些情况下,搜索算法的工作是找到最佳解决方案可能驻留的位置,同时被限制为只能看到一小部分搜索空间。图 1-2 是一个搜索算法通常会看到的例子。
图 1-2。
A more typical search fitness space
考虑一种算法,该算法在十亿(1,000,000,000)个可能的解决方案的搜索空间中进行搜索。即使每个解决方案只需要 1 秒钟来评估并分配一个适应值,也仍然需要 30 多年来明确搜索每个潜在的解决方案!如果我们不知道搜索空间中每个解决方案的适合度值,那么我们就无法确切地知道最佳解决方案位于何处。在这种情况下,唯一合理的方法是使用能够在可用的时间框架内找到足够好的解决方案的搜索算法。在这种情况下,遗传算法和进化算法在相对较短的时间内找到可行的、接近最优的解决方案是非常有效的。
遗传算法在搜索搜索空间时使用群体方法。作为其搜索策略的一部分,遗传算法将假设两个排序较好的解决方案可以组合起来,以形成更合适的后代。这个过程可以在我们的健身景观上可视化(图 1-3 )。
图 1-3。
Parent and offspring in the fitness plot
遗传算法中的变异算子允许我们搜索特定候选解的近邻。当突变应用于一个基因时,它的值是随机变化的。这可以通过在搜索空间上单步执行来描绘(图 1-4 )。
图 1-4。
A fitness plot showing the mutation
在交叉和变异的例子中,有可能得到比我们最初设定的更不合适的解决方案(图 1-5 )。
图 1-5。
A poor fitness solution
在这种情况下,如果解决方案表现不佳,最终将在选择过程中从基因库中删除。只要群体的平均趋势倾向于更合适的解决方案,单个候选解决方案中的小的负变化是好的。
局部最优
当实现优化算法时,应该考虑的一个障碍是该算法在搜索空间中能多好地脱离局部最优位置。为了更好地理解什么是局部最优,请参考图 1-6 。
图 1-6。
A local optimum can be deceiving
在这里,我们可以看到健身景观上的两座山峰,它们的高度略有不同。如前所述,优化算法无法看到整个适应度,相反,它能做的最好的事情是找到它认为可能在搜索空间中处于最佳位置的解决方案。正是由于这一特性,优化算法常常会不知不觉地将其搜索集中在搜索空间的次优部分。
当实现一个简单的爬山算法来解决任何足够复杂的问题时,这个问题很快变得显而易见。一个简单的爬山者没有任何固有的方法来处理局部最优,结果常常会在搜索空间的局部最优区域终止搜索。一个简单的随机爬山器相当于一个没有种群和交叉的遗传算法。该算法相当容易理解,它从搜索空间中的一个随机点开始,然后通过评估它的邻近解来试图找到一个更好的解。当爬山者在它的邻居中找到一个更好的解决方案时,它将移动到新的位置并重新开始搜索过程。这一过程将通过逐步爬上它在搜索空间中找到的任何一座山来逐渐找到改进的解决方案——因此得名“爬山者”。当爬山者再也找不到更好的解决方案时,它会认为自己在山顶,并停止搜索。
图 1-7 展示了爬山算法的典型运行情况。
图 1-7。
Shows how the hill climber works
上图展示了一个简单的爬山算法如何在搜索空间的一个局部最优区域开始搜索时轻松返回一个局部最优解。
虽然在没有首先评估整个搜索区域的情况下,没有任何保证的方法来避免局部最优,但是有许多算法的变体可以帮助避免局部最优。一种最基本、最有效的方法叫做随机重启爬山法,它简单地从随机的起始位置多次运行爬山算法,然后返回从各次运行中找到的最佳解决方案。这种优化方法相对容易实现,而且效果惊人。其他方法,如模拟退火(见 Kirkpatrick、Gelatt 和 Vecchi (1983))和禁忌搜索(见 Glover (1989)和 Glover (1990))是爬山算法的微小变化,它们都具有有助于减少局部最优解的特性。
遗传算法在避免局部最优和检索接近最优的解方面惊人地有效。实现这一点的方法之一是通过使种群能够对搜索空间的大区域进行采样,从而定位继续搜索的最佳区域。图 1-8 显示了初始化时人口的分布情况。
图 1-8。
Sample areas at initialization
在几代人过去之后,群体将开始朝着在前几代中可以找到最佳解决方案的方向一致。这是因为在选择过程中,不太适合的解决方案将被删除,为交叉和变异过程中产生的新的、更适合的解决方案让路(图 1-9 )。
图 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.
图 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 控制台上方的“终止”按钮来强制程序停止运行。
为了继续构建我们的遗传算法,我们需要实现两个额外的概念:交叉和变异。这些概念实际上通过随机突变和适者生存推动了种群的进化。
交叉
此时,是时候开始通过应用变异和交叉来进化种群了。交叉算子是群体中的个体交换遗传信息的过程,希望创造出一个包含其父母基因组中最好部分的新个体。
在交叉过程中,考虑群体中的每个个体进行交叉;这就是使用交叉率参数的地方。通过将交叉率与一个随机数进行比较,我们可以决定是否应该对该个体应用交叉,或者是否应该将其直接添加到不受交叉影响的下一个种群中。如果一个个体被选择进行杂交,那么就需要找到第二个亲本。为了找到第二个父母,我们需要从许多可能的选择方法中选择一个。
轮盘赌选择
轮盘赌轮选择-也称为适合度比例选择-是一种选择方法,它使用轮盘赌轮的类比来从群体中选择个体。这个想法是,根据个体的适应度值,将群体中的个体放在隐喻的轮盘赌上。个体的适应度越高,轮盘上分配的空间就越大。下图展示了个人在这一过程中的典型定位。
上面轮子上的每个数字代表了群体中的一个个体。个人的健康程度越高,他们在轮盘赌中的份额就越大。如果你现在想象旋转这个轮子,更有可能的是更健康的个体会被选中,因为他们占据了轮子上更多的空间。这就是为什么这种选择方法通常被称为适合度比例选择;因为解决方案的选择是基于它们的适合度与其余人群的适合度的比例。
我们可以使用许多其他选择方法,例如:锦标赛选择(第三章)和随机通用抽样(一种高级形式的健康比例选择)。然而,在这一章中,我们将实现一个最常见的选择方法:轮盘赌选择。在后面的章节中,我们将会看到其他的选择方法以及它们之间的区别。
交叉方法
除了在杂交过程中可以使用的各种选择方法之外,还有不同的方法来交换两个个体之间的遗传信息。不同的问题具有稍微不同的性质,并且使用特定的交叉方法效果更好。例如,全 1 问题只需要一个完全由 1 组成的字符串。一串“00111”与一串“10101”具有相同的适应值——它们都包含三个 1。对于这种类型的遗传算法,情况并不总是这样。假设我们试图创建一个字符串,这个字符串按照数字 1 到 5 的顺序排列。在这种情况下,字符串“12345”具有与“52431”非常不同的适合度值。这是因为我们不仅要寻找正确的数字,还要寻找正确的顺序。对于这样的问题,尊重基因顺序的杂交方法是更可取的。
我们将在这里实现的交叉方法是均匀交叉。在这种方法中,后代的每个基因有 50%的变化来自其第一个父母或第二个父母。
交叉伪码
现在我们有了一个选择和交叉方法,让我们看一些伪代码,这些代码概述了要实现的交叉过程。
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?
三、机器人控制器
介绍
在这一章中,我们将利用上一章学到的知识,通过遗传算法来解决一个现实世界中的问题。我们要解决的现实问题是设计机器人控制器。
遗传算法通常应用于机器人,作为设计复杂机器人控制器的方法,使机器人能够执行复杂的任务和行为,消除了手动编码复杂机器人控制器的需要。想象一下,你造了一个可以在仓库里运输货物的机器人。你已经安装了传感器,这使得机器人可以看到它的本地环境,并且你已经给了它轮子,所以它可以根据来自它的传感器的输入来导航。问题是如何将传感器数据与电机动作联系起来,以便机器人能够在仓库中导航。
遗传算法,更一般地说,达尔文进化论的思想被应用于机器人学的人工智能领域被称为进化机器人学。然而,这并不是解决这个问题的唯一自底向上的方法。通过使用强化学习算法来指导学习过程,神经网络也经常用于成功地将机器人传感器映射到输出。
通常,遗传算法将评估大量的个体,以为下一代寻找最佳个体。评估个人是通过运行适应度函数来完成的,该函数基于某些预定义的标准来衡量个人的表现。然而,将遗传算法及其适应度函数应用于物理机器人会带来新的挑战;对每个机器人控制器进行物理评估对于大量人群来说是不可行的。这是由于在物理上测试每个机器人控制器的困难以及这样做所花费的时间。出于这个原因,机器人控制器通常通过将它们应用于真实的物理机器人和环境的模拟模型来进行评估。这使得能够在软件中快速评估每个控制器,随后可以应用于它们的物理对应物。在这一章中,我们将使用二进制遗传算法的知识来设计一个机器人控制器,并开始将其应用于虚拟环境中的虚拟机器人。
问题
我们要解决的问题是设计一个机器人控制器,它可以使用机器人传感器来成功地引导机器人通过迷宫。机器人可以采取四种行动:向前移动一步,左转,右转,或者,很少,什么也不做。机器人也有六个传感器:三个在前面,一个在左边,一个在右边,一个在后面。
我们要探索的迷宫由机器人无法穿越的墙壁组成,并且将有一条轮廓分明的路线,如图 3-1 所示,我们希望机器人沿着这条路线前进。请记住,本章的目的不是训练机器人解决迷宫。我们的目的是给一个有六个传感器的机器人控制器自动编程,使它不会撞到墙上;我们只是用迷宫作为一个复杂的环境来测试我们的机器人控制器。
图 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”列表示传感器的位域,在将位域转换为十进制后,它映射到一个染色体位置。一旦将传感器的位域转换为十进制,就可以将所需的动作放在染色体的相应位置。
图 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 chapter
3;
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。
单点交叉比较容易实现,并且与均匀交叉相比,单点交叉可以更有效地从父节点传输连续的位组。这是交叉算法的一个有价值的特性。考虑我们的具体问题,其中染色体是基于六个传感器输入的一组编码指令,每个指令的长度超过一位。
想象一个理想的交叉情况如下:parent1 在前 32 次传感器操作中表现出色,parent2 在最后 16 次操作中表现出色。如果我们使用第二章中的统一交叉技术,我们会得到到处都是混乱的比特!由于均匀交叉随机选择位进行交换,单个指令将在交叉中被改变和破坏。两位指令可能根本不会被保留,因为每条指令的两位中的一位可能会被修改。然而,单点交叉让我们能够利用这种理想的情况。如果交叉点直接位于染色体的中间,那么后代将以 64 个不间断的位结束,代表来自父代 1 的 32 条指令,以及来自父代 2 的 16 条指令。因此,后代现在在 64 种可能状态中的 48 种上表现出色。这个概念是遗传算法的基础:后代可能比父母任何一方都强,因为它吸取了双方的最佳品质。
然而,单点交叉并非没有局限性。单点杂交的一个局限是父母基因组的某些组合是不可能的。例如,考虑两个父母:一个基因组为“00100”,另一个基因组为“10001”。孩子“10101”不可能单独通过杂交产生,尽管所需的基因在双亲中都存在。幸运的是,我们也有突变作为进化机制,如果交叉和突变都实施,基因组“10101”是可能的。
单点交叉的另一个限制是,向左的基因偏向于来自父代 1,向右的基因偏向于来自父代 2。为了解决这个问题,可以实现两点交叉,其中使用两个位置,允许分区跨越父代基因组的边缘。我们将两点交叉留给读者作为练习。
若要实现单点交叉,请将以下代码添加到 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?
四、旅行推销员
介绍
在这一章中,我们将探讨旅行推销员问题以及如何用遗传算法来解决它。在这样做的时候,我们将着眼于旅行推销员问题的性质,以及我们如何使用这些性质来设计遗传算法。
旅行推销员问题(TSP)是一个经典的优化问题,早在 19 世纪就被研究过。旅行推销员问题涉及到在一组城市中寻找最有效的路线,每个城市只去一次。
旅行推销员问题通常被描述为通过一组城市优化一条路线;然而,旅行推销员问题可以应用于其他应用。例如,城市的概念可以被认为是某些应用的客户,甚至是微芯片上的焊接点。距离的概念也可以修改,以考虑其他限制,如时间。
最简单的形式是,城市可以用图上的节点来表示,每个城市之间的距离用边的长度来表示(见图 4-1 )。“路线”或“旅程”简单地定义了应该使用哪些边,以及使用的顺序。然后,可以通过对路线中使用的边求和来计算路线的分数。
图 4-1。
Our graph showing the cities and the respective distances between them
在 20 世纪,许多数学家和科学家研究了旅行推销员问题;然而,这个问题至今仍未解决。产生旅行推销员问题的最优解的唯一有保证的方法是使用强力算法。强力算法是一种被设计成系统地尝试每一种可能的解决方案的算法。然后你从候选解的完整集合中找到最优解。试图用强力算法解决旅行推销员问题是一项极其困难的任务,因为随着城市数量的增加,潜在解决方案的数量呈阶乘增长。阶乘函数比指数函数增长得更快,这就是为什么很难暴力破解旅行推销员问题的原因。例如,对于 5 个城市,有 120 个可能的解决方案(1x2x3x4x5),对于 10 个城市,该数字将增加到 3,628,800 个解决方案!到 15 个城市,有超过一万亿个解决方案。在 60 个城市,可能的解决方案比可见宇宙中的原子还多。
当只有几个城市时,强力算法可以用来寻找最优解,但随着城市数量的增加,它们变得越来越具有挑战性。即使应用技术来消除反向和相同的路由,在合理的时间内找到最佳解决方案仍然很快变得不可行。
事实上,我们知道找到一个最优的解决方案通常是不必要的,因为一个足够好的解决方案通常是所需要的。有许多不同的算法可以快速找到可能只在几个百分点内的最优解。最常用的算法之一是最近邻算法。用这种算法,一个起始城市是随机挑选的。然后,找到下一个最近的未访问城市,并将其选作路线中的第二个城市。这个挑选下一个最近的未访问城市的过程一直持续到所有城市都被访问过并且找到了完整的路线。最近邻算法已经被证明在产生合理的解决方案方面是令人惊讶地有效的,该合理的解决方案的分数在最优解决方案的分数之内。更好的是,这可以在很短的时间内完成。这些特性使它在许多情况下成为一个有吸引力的解决方案,并且是遗传算法的一个可能的替代方案。
问题
我们将在这个实现中处理的问题是一个典型的旅行推销员问题,在这个问题中,我们需要优化通过一组城市的路线。我们可以通过将每个城市设置到一个随机的 x,y 位置,在 2D 空间中生成一些随机的城市。
当寻找两个城市之间的距离时,我们将简单地使用两个城市之间最短的长度作为距离。我们可以用下面的等式来计算这个距离:
通常情况下,问题会比这更复杂。在这个例子中,我们假设每个城市之间存在一条直接的理想路径;这也被称为“欧几里德距离”。这通常不是典型的情况,因为可能存在各种障碍,使得实际最短路径比欧几里德距离长得多。我们还假设从城市 A 到城市 B 的旅行时间与从城市 B 到城市 A 的旅行时间一样长。同样,现实中很少会出现这种情况。往往会有单行道之类的障碍物,在某个方向行驶时会影响城市间的距离。城市之间的距离根据方向而变化的旅行推销员问题的实现被称为非对称旅行推销员问题。
履行
是时候使用我们的遗传算法知识来解决这个问题了。在为这个问题设置了一个新的 Java/Eclipse 包之后,我们将开始对路由进行编码。
开始之前
本章将基于你在第三章中开发的代码。在开始之前,创建一个新的 Eclipse 或 NetBeans 项目,或者在现有项目中为这本书创建一个名为“第四章”的新包。
从第三章的中复制个体、群体和遗传算法类,并将它们导入到第四章的中。确保更新每个类文件顶部的包名!最上面应该都写着“包章 4 ”。
打开 GeneticAlgorithm 类并删除以下方法:calcFitness、evalPopulation、crossoverPopulation 和 mutatePopulation。你将在本章的课程中重写这些方法。
接下来,打开 Individual 类,删除签名为“public Individual(int chromosomelongth)”的构造函数。单个类中有两个构造函数,所以要小心删除正确的那个!要删除的构造函数是随机初始化染色体的那个;在这一章中你也将重写它。
第三章中的 Population 类只需要修改文件顶部的包名。
编码
我们在这个例子中选择的编码需要能够按顺序对城市列表进行编码。我们可以为每个城市分配一个唯一的 ID,然后按照候选路线的顺序使用染色体来引用它。这种使用基因序列的编码被称为排列编码,非常适合旅行推销员问题。
我们需要做的第一件事是给我们的城市分配唯一的 id。如果我们要访问 5 个城市,我们可以简单地给它们分配 IDs,2,3,4,5。然后,当我们的遗传算法找到一条路线时,我们的染色体可能会将城市 id 排序如下:3,4,1,2,5。这只是意味着我们将从城市 3 开始,然后前往城市 4,然后城市 1,然后城市 2,然后城市 5,然后返回城市 3 完成路线。
初始化
在我们开始优化路线之前,我们需要创建一些城市。如前所述,我们可以通过选择随机的 x,y 坐标来生成随机的城市,并使用它们来定义一个城市位置。
首先,我们需要创建一个 City 类,它可以创建和存储一个城市,并计算到另一个城市的最短距离。
package chapter4;
public class City {
private int x;
private int y;
public City(int x, int y) {
this.x = x;
this.y = y;
}
public double distanceFrom(City city) {
// Give difference in x,y
double deltaXSq = Math.pow((city.getX() - this.getX()), 2);
double deltaYSq = Math.pow((city.getY() - this.getY()), 2);
// Calculate shortest path
double distance = Math.sqrt(Math.abs(deltaXSq + deltaYSq));
return distance;
}
public int getX() {
return this.x;
}
public int getY() {
return this.y;
}
}
City 类有一个构造函数,它采用 x 和 y 坐标在 2D 平面上创建一个城市。该类还包含一个 distanceFrom 方法,该方法使用勾股定理计算从当前城市到另一个城市的直线距离。最后,有两个 getter 方法可用于检索城市的 x 和 y 位置。
接下来,我们应该恢复在“开始之前”一节中删除的单个类构造函数。旅行推销员问题对染色体的约束与我们上两个问题不同。回想一下,机器人控制器问题中的唯一约束是染色体必须是 128 位长,并且必须是二进制的。
不幸的是,旅行推销员问题并非如此;约束更加复杂,并规定了我们可以使用的初始化、交叉和变异技术。在这种情况下,染色体必须有一定的长度(无论城市游览有多长),但一个额外的约束是每个城市必须游览一次且只能游览一次,否则染色体无效。染色体中不能有重复的基因,染色体中不能有省略的城市。
我们可以很容易地创建一个没有任何随机性的独立构造函数。简单地创建一个染色体,其中包含每个城市的索引:1、2、3、4、5、6……等等。随机排列初始染色体是读者在本章末尾的一个练习。
将下面的构造函数添加到单个类中。你可以把它放在任何你喜欢的地方,但是靠近顶部是构造函数的好位置。和往常一样,这里省略了注释和文档块,但是请参阅本书附带的 Eclipse 项目以获得更多注释。
public Individual(int chromosomeLength) {
// Create random individual
int[] individual;
individual = new int[chromosomeLength];
for (int gene = 0; gene < chromosomeLength; gene++) {
individual[gene] = gene;
}
this.chromosome = individual;
}
至此,我们可以创建我们的执行类及其“main”方法了。通过使用文件➤新➤类菜单项,在包“第四章中创建一个名为“TSP”的新 Java 类。正如在第三章中,我们将使用一些 TODOs 来剔除遗传算法伪代码,这样我们就可以通过实现来标记我们的进展。
让我们借此机会在“main”方法的顶部初始化一个由 100 个随机生成的城市对象组成的数组。只需生成随机的 x 和 y 坐标,并将它们传递给 City 构造函数。确保您的 TSP 类如下所示:
package chapter4;
public class TSP {
public static int maxGenerations = 3000;
public static void main(String[] args) {
int numCities = 100;
City cities[] = new City[numCities];
// Loop to create random cities
for (int cityIndex = 0; cityIndex < numCities; cityIndex++) {
int xPos = (int) (100 * Math.random());
int yPos = (int) (100 * Math.random());
cities[cityIndex] = new City(xPos, yPos);
}
// Initial GA
GeneticAlgorithm ga = new GeneticAlgorithm(100, 0.001, 0.9, 2, 5);
// Initialize population
Population population = ga.initPopulation(cities.length);
// TODO: Evaluate population
// Keep track of current generation
int generation = 1;
// Start evolution loop
while (ga.isTerminationConditionMet(generation, maxGenerations) == false) {
// TODO: Print fittest individual from population
// TODO: Apply crossover
// TODO: Apply mutation
// TODO: Evaluate population
// Increment the current generation
generation++;
}
// TODO: Display results
}
}
希望这个过程越来越熟悉;我们再次开始实现在第二章的开头出现的伪代码。我们还生成了一个城市对象的数组,我们将在我们的评估方法中使用,就像我们在上一章中如何生成一个迷宫对象来评估个人一样。
剩下的就是死记硬背了:初始化一个 GeneticAlgorithm 对象(包括种群大小、突变率、交叉率、精英计数和锦标赛规模),然后初始化一个种群。个人的染色体长度必须与我们希望访问的城市数量相同。
我们可以重用上一章中简单的“最大代数”终止条件,所以这次我们只剩下六个 TODOs 和一个工作循环。像往常一样,让我们从评估和健康评分方法开始。
估价
现在,我们需要评估群体,并为个体分配适合度值,这样我们就知道哪个表现最好。第一步是定义问题的适应度函数。这里,我们只需要计算出个体的染色体给出的路线的总距离。
首先,我们需要创建一个新的类来存储一条路线并计算它的总距离。在 package "chapter 4 中创建一个名为" Route "的新类,并插入以下代码:
package chapter4;
public class Route {
private City route[];
private double distance = 0;
public Route(Individual individual, City cities[]) {
// Get individual’s chromosome
int chromosome[] = individual.getChromosome();
// Create route
this.route = new City[cities.length];
for (int geneIndex = 0; geneIndex < chromosome.length; geneIndex++) {
this.route[geneIndex] = cities[chromosome[geneIndex]];
}
}
public double getDistance() {
if (this.distance > 0) {
return this.distance;
}
// Loop over cities in route and calculate route distance
double totalDistance = 0;
for (int cityIndex = 0; cityIndex + 1 < this.route.length; cityIndex++) {
totalDistance += this.route[cityIndex].distanceFrom(this.route[cityIndex + 1]);
}
totalDistance += this.route[this.route.length - 1].distanceFrom(this.route[0]);
this.distance = totalDistance;
return totalDistance;
}
}
这个类只包含一个构造函数和一个计算总路线距离的方法。构造函数接受一个个体和一个城市定义列表(与我们在 TSP 类的“main”函数中创建的城市数组相同)。然后,构造函数按照个体染色体的顺序构建一个城市对象数组;这种数据结构使得在 getDistance 方法中评估总路径距离变得简单。
getDistance 方法遍历 route 数组(城市对象的有序数组),并调用 City 类的“distanceFrom”方法依次计算两个城市之间的距离,并进行求和。
为了实现这种适应性评分方法,我们需要更新 GeneticAlgorithm 类中的 calcFitness 函数。calcFitness 类应该将距离计算委托给 Route 类,为此,它需要接受我们的城市定义数组并将其传递给 Route 类。
将下面的方法添加到 GeneticAlgorithm 类中文件的任意位置。
public double calcFitness(Individual individual, City cities[]){
// Get fitness
Route route = new Route(individual, cities);
double fitness = 1 / route.getDistance();
// Store fitness
individual.setFitness(fitness);
return fitness;
}
在此函数中,适合度的计算方法是用 1 除以总路线距离,因此距离越短得分越高。计算出适合度后,会将其存储起来,以备再次需要时快速调用。
现在,我们可以在 GeneticAlgorithm 类中更新我们的 evalPopulation 方法,以接受 cities 参数并找到群体中每个个体的适合度。
public void evalPopulation(Population population, City cities[]){
double populationFitness = 0;
// Loop over population evaluating individuals and summing population fitness
for (Individual individual : population.getIndividuals()) {
populationFitness += this.calcFitness(individual, cities);
}
double avgFitness = populationFitness / population.size();
population.setPopulationFitness(avgFitness);
}
像往常一样,这个函数在群体中循环,计算每个个体的适应度。与之前的实现不同,我们计算的是平均群体适应度,而不是总群体适应度。(因为我们使用的是锦标赛选择而不是轮盘赌选择,所以我们实际上不需要群体的适应性;如果我们不记录这个值,什么都不会改变。)
现在,我们可以解析 TSP 类中与评估和显示结果相关的“main”方法中的四个 TODOs。更新 TSP 类以表示以下内容。解决的四个 TODOs 是两个“评估群体”行(循环前和循环内),循环顶部的“打印群体中最合适的个体”行,以及循环后的“显示结果”行。
package chapter4;
public class TSP {
public static int maxGenerations = 3000;
public static void main(String[] args) {
int numCities = 100;
City cities[] = new City[numCities];
// Loop to create random cities
for (int cityIndex = 0; cityIndex < numCities; cityIndex++) {
int xPos = (int) (100 * Math.random());
int yPos = (int) (100 * Math.random());
cities[cityIndex] = new City(xPos, yPos);
}
// Initial GA
GeneticAlgorithm ga = new GeneticAlgorithm(100, 0.001, 0.9, 2, 5);
// Initialize population
Population population = ga.initPopulation(cities.length);
// Evaluate population
ga.evalPopulation(population, cities);
// Keep track of current generation
int generation = 1;
// Start evolution loop
while (ga.isTerminationConditionMet(generation, maxGenerations) == false) {
// Print fittest individual from population
Route route = new Route(population.getFittest(0), cities);
System.out.println("G"+generation+" Best distance: " + route.getDistance());
// TODO: Apply crossover
// TODO: Apply mutation
// Evaluate population
ga.evalPopulation(population, cities);
// Increment the current generation
generation++;
}
// Display results
System.out.println("Stopped after " + maxGenerations + " generations.");
Route route = new Route(population.getFittest(0), cities);
System.out.println("Best distance: " + route.getDistance());
}
}
此时,我们可以单击“Run ”,循环将执行这些动作,将相同的内容打印 3000 次,但没有显示任何变化。这当然是意料之中的;我们需要实现交叉和变异,作为我们剩下的两个目标。
终止检查
正如我们已经了解到的,除非我们尝试每一种可能的解决方案,否则没有办法知道我们是否找到了旅行推销员问题的最优解决方案。这意味着我们在这个实现中使用的终止检查不能在找到最优解时终止,因为它根本无法知道。
既然我们无法在找到最优解时终止,我们可以简单地允许算法在最终终止前运行一定数量的代,因此我们能够重用第三章的 GeneticAlgorithm 类中的 isTerminationConditionMet 方法。
但是,请注意,在这种情况下——最佳解决方案无法得知——除了简单地设置代数上限之外,还有许多复杂的终止技术。
一种常见的技术是测量随着时间的推移群体健康的改善。如果人口仍在快速增长,您可能希望允许算法继续运行。一旦种群停止改善,你就可以结束进化,提出最优解。
您可能永远也不会在像旅行推销员问题这样的复杂解决方案空间中找到全局最优解,但是存在许多强局部最优解,并且进展中的平稳状态通常表明您已经找到了这些局部最优解中的一个。
有几种方法可以测量遗传算法随时间的进展。最简单的方法是测量最佳个体没有改进的连续世代的数量。如果没有改进的代数超过了某个阈值,例如 500 代没有改进,您可以停止该算法。
这种具有大解空间的简单方法的一个缺点是,您可能会看到群体的适应度不断提高——这可能非常慢!有如此多的组合,以至于每十几代就有一个点的改善是可行的,你永远不会遇到连续 500 代都没有改善的情况。当然,你可以设置一个不考虑改进的最大代数上限。您还可以实现一种更复杂的技术,比如获取不同窗口的移动平均值,并将它们相互比较。如果适应性改善在几个窗口内一直呈下降趋势,则停止该算法。
然而,在我们的例子中,我们将坚持使用第三章中的简单方法,并让读者在本章末尾实现一个更好的终止条件作为练习。
交叉
对于旅行推销员问题,基因和基因在染色体中的顺序都非常重要。事实上,对于旅行推销员问题,我们的染色体中不应该有一个以上的特定基因副本。这是因为它会创建一个无效的解决方案,因为一个城市在一条给定的路径上不应被访问超过一次。考虑这样一种情况,我们有三个城市:城市 A、城市 B 和城市 C。A、B、C 的一条路线是有效的;然而,C,B,C 的路线不是:这条路线访问城市 C 两次,也从未访问城市 a。因此,我们必须找到并应用交叉方法,为我们的问题产生有效的结果。
在杂交过程中,我们还需要尊重父母染色体的排序。这是因为染色体的顺序会影响解决方案的适应性。事实上,重要的只是顺序。为了更好地理解为什么会出现这种情况,请考虑以下两条路线是如何完全不同的,尽管它们包含完全相同的基因:
Route 1: A,B,C,D,E
Route 2: C,A,D,B,E
我们之前看了均匀交叉;然而,统一交叉方法在单个基因的水平上起作用,并且不考虑染色体的顺序。单点和两点交叉方法做得更好,因为它们处理染色体块,这将保持这些块内的顺序。然而,单点和两点杂交的问题是,它们并不关心染色体上哪些基因被添加或删除。这意味着我们很可能最终得到无效的解决方案,其染色体包含一个以上对同一城市的引用,或者完全缺少城市。
解决这两个问题的交叉方法是有序交叉。在这种交叉方法中,选择第一个父代染色体的子集。然后将该子集添加到子染色体的相同位置。
下一步是将第二个父母的遗传信息添加到后代的染色体中。我们这样做是从所选子集的末端位置开始,然后包括来自父代 2 的每个基因,这些基因还没有出现在后代的染色体中。
在这个例子中,我们将从基因“2”开始,检查它是否能在后代的染色体中找到。因为 2 目前不在后代的染色体中,所以我们可以将它添加到后代染色体中第一个可用的位置。然后,因为我们到达了双亲 2 的染色体的末端,我们回到第一个基因,“5”。这一次,5 在后代的染色体中,所以我们跳过它,移到 1。我们一直这样做,直到得到以下结果:
这种交叉方法保留了许多来自父代的顺序,但也确保了解决方案对于旅行推销员问题等问题仍然有效。
这种算法有一个方面是我们目前的单个类无法实现的:这项技术需要检查后代的染色体是否存在特定的基因。在前面的章节中,我们没有特定的基因——我们有二元染色体——所以没有必要实现一种方法来检查染色体中基因的存在。
幸运的是,这是一个很容易添加的方法。打开单个类,在文件中的任意位置添加一个名为“containsGene”的方法:
public boolean containsGene(int gene) {
for (int i = 0; i < this.chromosome.length; i++) {
if (this.chromosome[i] == gene) {
return true;
}
}
return false;
}
这个方法查看染色体中的每个基因,如果它找到了它正在寻找的基因,它将返回 true 否则返回 false。这个方法的使用解决了这个问题:“这个解决方案访问城市#5 吗?让我们调用 individual.containsGene(5)来找出答案。”
我们现在准备通过更新 genetic algorithm 类来将我们的有序交叉方法应用于我们的遗传算法。像上一章一样,我们可以实现锦标赛选择作为我们用于交叉的选择方法,但是我们没有修改上一章的 selectParent 方法。
将此 crossoverPopulation 方法添加到 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++) {
// Get parent1
Individual parent1 = population.getFittest(populationIndex);
// Apply crossover to this individual?
if (this.crossoverRate > Math.random() && populationIndex >= this.elitismCount) {
// Find parent2 with tournament selection
Individual parent2 = this.selectParent(population);
// Create blank offspring chromosome
int offspringChromosome[] = new int[parent1.getChromosomeLength()];
Arrays.fill(offspringChromosome, -1);
Individual offspring = new Individual(offspringChromosome);
// Get subset of parent chromosomes
int substrPos1 = (int) (Math.random() * parent1.getChromosomeLength());
int substrPos2 = (int) (Math.random() * parent1.getChromosomeLength());
// make the smaller the start and the larger the end
final int startSubstr = Math.min(substrPos1, substrPos2);
final int endSubstr = Math.max(substrPos1, substrPos2);
// Loop and add the sub tour from parent1 to our child
for (int i = startSubstr; i < endSubstr; i++) {
offspring.setGene(i, parent1.getGene(i));
}
// Loop through parent2's city tour
for (int i = 0; i < parent2.getChromosomeLength(); i++) {
int parent2Gene = i + endSubstr;
if (parent2Gene >= parent2.getChromosomeLength()) {
parent2Gene -= parent2.getChromosomeLength();
}
// If offspring doesn’t have the city add it
if (offspring.containsGene(parent2.getGene(parent2Gene)) == false) {
// Loop to find a spare position in the child’s tour
for (int ii = 0; ii < offspring.getChromosomeLength(); ii++) {
// Spare position found, add city
if (offspring.getGene(ii) == -1) {
offspring.setGene(ii, parent2.getGene(parent2Gene));
break;
}
}
}
}
// Add child
newPopulation.setIndividual(populationIndex, offspring);
} else {
// Add individual to new population without applying crossover
newPopulation.setIndividual(populationIndex, parent1);
}
}
return newPopulation;
}
在这种方法中,我们首先创建一个新的种群来容纳后代。然后,当前种群按照最适合的个体的顺序循环。如果精英主义被启用,最初的几个精英个体被跳过,并被不加改变地添加到新群体中。然后使用交叉率考虑剩余的个体进行交叉。如果要对个体应用交叉,则使用 selectParent 方法选择一个父代(在这种情况下,selectParent 执行锦标赛选择,如第三章中的所示),并创建一个新的空白个体。
接下来,在亲代 1 的染色体中随机选取两个位置,并将这两个位置之间的遗传信息子集添加到后代的染色体中。最后,所需的剩余遗传信息按照在 parent2 中找到的顺序添加;然后当完成时,该个体被添加到新的群体中。
现在,我们可以将 crossoverPopulation 方法实现到 TSP 类中的“main”方法中,并解析我们的一个 TODOs。找到“TODO:应用交叉”并替换为:
// Apply crossover
population = ga.crossoverPopulation(population);
此时单击“运行”应该会产生一个有效的算法!经过 3,000 代之后,您应该会看到大约 1,500 的最佳距离。但是,您可能还记得,单独的交叉容易陷入局部最优,您可能会发现算法停滞不前。变异是我们在解决方案空间的新位置随机丢弃候选对象的方式,可以帮助以短期收益为代价改善长期结果。
变化
像交叉一样,我们在旅行推销员问题中使用的变异类型很重要,因为我们需要再次确保染色体在应用后是有效的。我们随机改变一个基因的单个值的方法可能会导致染色体重复,结果染色体将是无效的。
一个简单的解决方案叫做交换突变,这是一种简单交换两点遗传信息的算法。交换突变通过循环个体染色体中的基因来工作,每个基因都被认为是由突变率决定的突变。如果一个基因被选择突变,染色体中的另一个随机基因被挑选出来,然后它们的位置被交换。
这个过程确保不会产生重复的基因,并且任何产生的后代都将是有效的解决方案。
要实现这个突变方法,首先要将 mutatePopulation 方法添加到 GeneticAlgorithm 类中。
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);
// Skip mutation if this is an elite individual
if (populationIndex >= this.elitismCount) {
// System.out.println("Mutating population member "+populationIndex);
// Loop over individual’s genes
for (int geneIndex = 0; geneIndex < individual.getChromosomeLength(); geneIndex++) {
// Does this gene need mutation?
if (this.mutationRate > Math.random()) {
// Get new gene position
int newGenePos = (int) (Math.random() * individual.getChromosomeLength());
// Get genes to swap
int gene1 = individual.getGene(newGenePos);
int gene2 = individual.getGene(geneIndex);
// Swap genes
individual.setGene(geneIndex, gene1);
individual.setGene(newGenePos, gene2);
}
}
}
// Add individual to population
newPopulation.setIndividual(populationIndex, individual);
}
// Return mutated population
return newPopulation;
}
这个方法的第一步是创建一个新的群体来容纳变异的个体。接下来,种群从最适合的个体开始循环。如果精英主义被启用,最初的几个个体被跳过并被不加改变地添加到新群体中。然后剩余个体的染色体循环,根据突变率单独考虑每个基因的突变。如果一个基因发生突变,从个体中随机挑选另一个基因,并交换这些基因。最后,变异的个体被添加到新的种群中。
现在,我们可以将突变方法添加到 TSP 类的“main”方法中,并解析我们的最终 TODO。找到注释“TODO: Apply mutation”并替换为:
// Apply mutation
population = ga.mutatePopulation(population);
执行
TSP 类的最终代码应该如下所示:
package chapter4;
public class TSP {
public static int maxGenerations = 3000;
public static void main(String[] args) {
// Create cities
int numCities = 100;
City cities[] = new City[numCities];
// Loop to create random cities
for (int cityIndex = 0; cityIndex < numCities; cityIndex++) {
// Generate x,y position
int xPos = (int) (100 * Math.random());
int yPos = (int) (100 * Math.random());
// Add city
cities[cityIndex] = new City(xPos, yPos);
}
// Initial GA
GeneticAlgorithm ga = new GeneticAlgorithm(100, 0.001, 0.9, 2, 5);
// Initialize population
Population population = ga.initPopulation(cities.length);
// Evaluate population
//ga.evalPopulation(population, cities);
Route startRoute = new Route(population.getFittest(0), cities);
System.out.println("Start Distance: " + startRoute.getDistance());
// Keep track of current generation
int generation = 1;
// Start evolution loop
while (ga.isTerminationConditionMet(generation, maxGenerations) == false) {
// Print fittest individual from population
Route route = new Route(population.getFittest(0), cities);
System.out.println("G"+generation+" Best distance: " + route.getDistance());
// Apply crossover
population = ga.crossoverPopulation(population);
// Apply mutation
population = ga.mutatePopulation(population);
// Evaluate population
ga.evalPopulation(population, cities);
// Increment the current generation
generation++;
}
System.out.println("Stopped after " + maxGenerations + " generations.");
Route route = new Route(population.getFittest(0), cities);
System.out.println("Best distance: " + route.getDistance());
}
}
此外,根据以下属性和方法签名检查 GeneticAlgorithm 类。如果您错过了实现这些方法之一,或者您的方法签名之一不匹配,请现在返回并解决该问题。
package chapter4;
import java.util.Arrays;
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 boolean isTerminationConditionMet(int generationsCount, int maxGenerations) { }
public double calcFitness(Individual individual, City cities[]) { }
public void evalPopulation(Population population, City cities[]) { }
public Individual selectParent(Population population) { }
public Population crossoverPopulation(Population population) { }
public Population mutatePopulation(Population population) { }
}
最后,确保您已经通过替换构造函数和添加 containsGene 方法更新了单个类。
此时,单击“Run”并观察输出。像往常一样,提醒自己遗传算法是由统计数据决定的随机过程,你不能仅凭一次试验就得出任何结论。不幸的是,这个问题初始化了一组随机的城市,这意味着程序的每次运行都会有不同的最优解;这使得试验遗传算法参数和判断它们的性能变得困难。本章末尾的第一个练习是硬编码一个城市列表,这样您就可以准确地对算法的性能进行基准测试。
然而,在这一点上你可以做一些有趣的观察。多次运行该算法,观察最佳距离。都差不多吗?至少在同一个球场?这很有趣,因为每次运行使用不同的城市。但仔细想想,这是有道理的:虽然这些城市每次都在不同的位置,但每次仍然有 100 个城市,它们仍然随机地放置在 100x100 的地图上,这意味着我们可以很容易地估计问题解决方案的总距离。
假设有一张 100x100 的地图,其面积为 10,000 个单位,但是您的目标不是访问 100 个城市,而是访问 10,000 个城市。如果城市被均匀地放置在地图上(每个网格点上一个),最佳解决方案应该是正好 10,100 的距离(以之字形访问地图上的每个分块)。如果不是平均分布这些城市,而是随机分布这 10,000 个城市,最佳解决方案将是以 10,000 为中心的统计分布,由于位置的随机性,每次运行都会略有不同。
现在我们可以倒推,考虑更少的城市;在地图上均匀地放置 25 个城市,最短的路线是 600 个单位。这里的关系变得很清楚:距离与地图面积的平方根乘以城市数量的平方根有关。利用这种关系,我们发现 100 个均匀放置的城市的最小距离为 1100(即;最后增加的平方根项说明了南北向的移动,但是当我们开始从统计学角度来说时,我们可以去掉这个项。如果我们在地图上随机放置同样的 100 个城市,我们可以预期最小距离是以 1000 为中心的分布。同样,1000 个城市应该有 3100 附近的最佳距离。
如果你研究一下城市的数量,你会发现,对于较小的数字,该算法很容易证实这些怀疑,但自然地,它很难找到超过 100 个城市的最小值。
既然我们已经了解了地图大小、城市数量和预期最佳距离之间的关系,我们甚至可以在没有一组固定城市的情况下进行实验,并使用我们的统计预期来代替。一个特别感兴趣的领域是突变对结果质量的影响。
如果你把解空间想象成一个有很多起伏的山丘的景观,遗传算法就像把 100 个有不同行为的人放在景观的随机位置,看哪个人找到了最低的山谷。(在这种情况下,因为我们的个体构造函数不是随机的,所以我们实际上是在同一个点上删除所有个体。)经过几代人,个体和他们的后代会向山下移动,然后当他们找到附近最低的山谷时停下来。然而,突变将它们捡起来,放入一个新的随机位置——这个位置可能比前一个更好或更差,但至少这是一个新的、独特的位置,允许它们在一个新的环境中继续搜索。
然而,突变通常会有短期的危害。突变可能是有利的,也可能是不利的——这就是为什么我们使用精英主义来保护最优秀的个体免受突变的影响。然而,突变引入的多样性可能会产生深远的长期影响,因为它将一个人置于一个否则可能无法探索的景观中:想象一座中间有一个巨大裂缝的高大火山,其中包含景观中的最低点(全球最佳状态,周围是不利的景观)。任何群体都不太可能爬上火山并在中心找到全局最优解——除非随机突变将一个个体置于火山边缘。
话虽如此,但观察不同变异率和精英主义计数对长期运行(数万代)难题(200 个城市或更多)的影响。突变是有益还是有害?精英主义是有益还是有害?
摘要
旅行商问题是一个经典的优化问题,它问:在一列城市之间,访问每个城市一次,然后返回初始城市的最短可能路线是什么?
这是一个未解决的优化问题,其中只有使用强力算法才能找到最优解。然而,由于旅行推销员问题的可能解决方案的数量随着每个城市的增加而快速增长,即使使用最强大的计算机,暴力解决方案也很快变得不可行。对于这些情况,启发式方法被用来寻找一个好的近似。
我们介绍了旅行推销员问题的一个基本实现,使用 2D 图上的城市并用直线距离将它们连接起来。
使用城市 id 的有序列表作为染色体编码,我们能够表示旅行推销员问题的解决方案。但是,因为每个城市 ID 必须在编码中至少出现一次,而且只能出现一次,所以我们研究了两种新的交叉和变异方法,它们可以保持这种约束:有序交叉和交换变异。
在有序交叉中,父代 1 染色体的随机子集被添加到后代中,然后所需的剩余遗传信息按照在父代 2 染色体中找到的顺序被添加到后代中。这种添加 parent1 的遗传信息子集,然后只添加 parent2 中缺失的剩余遗传信息的方法保证了每个城市 ID 在解决方案中只出现一次。
在交换突变中,选择两个基因并交换它们的位置。同样,这种变异方法保证了旅行推销员问题的有效解决方案,因为它不允许完全删除城市 id,也不会导致城市出现两次。
练习
Hard-code cities into the TSP class “main” method so that you can accurately take benchmarks of performance. Add support for both, shortest route and quickest route using user defined distances and times between cities. Add support for an asymmetric TSP (the cost of traveling from A to B may not equal the cost from traveling from B to A). Modify the Individual class constructor to randomize the order of cities. How does this affect the performance of the algorithm? Update the termination condition to measure the algorithm’s progress and quit when no significant progress is being made. How does this affect the algorithm’s performance and results?
五、课程表
介绍
在这一章中,我们将创建一个遗传算法来为大学课程表安排课程。我们将考察几个不同的场景,在这些场景中可能会用到排课算法,以及在设计课程表时通常会用到的约束条件。最后,我们将构建一个简单的类调度器,它可以扩展以支持更复杂的实现。
在人工智能中,排课问题是约束满足问题的一个变种。这类问题与问题有关,这些问题有一组变量,需要以避免违反一组已定义的约束的方式进行分配。
约束分为两类:硬约束——产生功能解决方案需要满足的约束,以及软约束——首选但不以牺牲硬约束为代价的约束。
例如,当制造新产品时,产品的功能需求是硬约束,并指定重要的性能需求。没有这些约束,你就没有产品。不能打电话的电话根本算不上电话!然而,你也可能有软约束,虽然不是必需的,但考虑起来仍然很重要,比如产品的成本、重量或美观。
当创建一个排课算法时,通常会有许多硬约束和软约束需要考虑。排课问题的一些典型的硬约束是:
- 教授在任何时候都只能在一个班级
- 教室需要足够大以容纳整个班级
- 教室在任何给定时间只能容纳一个班级
- 教室必须包含任何必需的设备
一些典型的软约束可能是:
- 教室容量应适合班级规模
- 教授的首选教室
- 教授的首选上课时间
有时,多个软约束可能会冲突,需要在它们之间找到一个折衷方案。例如,一个班级可能只有 10 名学生,因此软约束可以奖励分配一个合适的教室,其容量约为 10 人;然而,上课的教授可能更喜欢能容纳 30 名学生的大教室。如果教授偏好被认为是软约束,那么这些配置中的一个将是优选的,并且有希望被课程调度器找到。
在更高级的实现中,还可以对软约束进行加权,以便算法了解哪些软约束是最需要考虑的。
像旅行推销员问题一样,迭代方法可以用来寻找班级调度问题的最优解;然而,随着类别配置数量的增加,找到最佳解决方案变得越来越困难。在这些情况下,当类别配置的可能数量超过通过迭代方法解决的可行数量时,遗传算法是很好的替代方法。虽然他们不能保证找到最优解,但他们非常擅长在合理的时间内找到接近最优的解。
问题
在这一章中,我们将讨论的排课问题是一个大学排课器,它可以根据我们提供的数据创建一个大学课程表,比如可用的教授、可用的教室、时间段和学生群体。
我们应该注意,建立大学时间表与建立小学时间表略有不同。小学的时间表要求他们的学生有一个全天的完整时间表,没有空闲时间。相反,典型的大学时间表通常会有自由时间,这取决于学生注册了多少个模块。
每堂课都将被安排一个时间段,一个教授,一个教室和一个学生小组。我们可以通过将学生组的数量乘以每个学生组注册的模块数量相加来计算需要安排的班级总数。
对于我们的应用安排的每个类,我们将考虑以下硬约束:
- 只能安排在免费教室上课
- 一个教授在任何时候只能教一门课
- 教室必须足够大,以容纳学生群体
为了在这个实现中保持简单,我们现在只考虑硬约束;然而,取决于时间表规范,通常会有更多的硬约束。规范中还可能包含许多软约束,现在我们将忽略它们。虽然没有必要,但考虑软约束通常会对遗传算法生成的时间表的质量产生很大影响。
履行
是时候使用我们的遗传算法知识来解决这个问题了。在为这个问题建立了一个新的 Java/Eclipse 包之后,我们将从编码染色体开始。
开始之前
本章将建立在您在前面所有章节中开发的代码的基础上——因此,密切关注这一部分尤其重要!
在开始之前,创建一个新的 Eclipse 或 NetBeans 项目,或者在现有项目中为这本书创建一个名为“chapter 5 ”的新包。
从第四章复制个体、群体和遗传算法类,并将它们导入第五章。确保更新每个类文件顶部的包名!最上面应该都写着“包章 5 ”。
打开 GeneticAlgorithm 类并进行以下更改:
- 删除 selectParent 方法,并用第三章(锦标赛选择)中的 selectParent 方法替换它
- 删除交叉过剩法,并将其替换为第二章中的交叉过剩法(均匀交叉)
- 删除 initPopulation、mutatePopulation、evalPopulation 和 calcPopulation 方法——您将在本章中重新实现它们
Population 和 Individual 类现在可以不考虑,但是请记住,在本章的后面,您将为每个文件添加一个新的构造函数。
编码
我们在课程安排应用中使用的编码需要能够有效地编码我们需要的所有课程属性。对于这个实现,它们是:课程安排的时间段、教授上课的教授和课程的教室。
我们可以简单地给每个时间段、教授和教室分配一个数字 ID。然后我们可以使用编码整数数组的染色体——我们熟悉的方法。这意味着每个需要调度的类只需要三个整数来编码,如下所示:
通过将这个数组分成三个块,我们可以检索每个类所需的所有信息。
初始化
现在我们已经了解了我们的问题,以及我们将如何对染色体进行编码,我们可以开始实现了。首先,我们需要为我们的调度程序创建一些数据:特别是我们试图围绕其建立时间表的教室、教授、时间段、模块和学生团体。
通常这些数据来自包含完整课程模块和学生数据的数据库。然而,为了实现这个目的,我们将创建一些硬编码的伪数据来使用。
让我们首先设置我们的支持 Java 类。我们将为上面的每种数据类型(房间、班级、小组、教授、模块和时间段)创建一个容器类。虽然每个容器类都非常简单,但它们大多定义了一些类属性、getters 和 setters,没有真正的逻辑。我们将在这里依次打印它们。
首先,创建一个存储教室信息的 Room 类。和往常一样,如果使用 Eclipse,您可以使用文件➤新➤类菜单选项创建这个类。
package chapter``5
public class Room {
private final int roomId;
private final String roomNumber;
private final int capacity;
public Room(int roomId, String roomNumber, int capacity) {
this.roomId = roomId;
this.roomNumber = roomNumber;
this.capacity = capacity;
}
public int getRoomId() {
return this.roomId;
}
public String getRoomNumber() {
return this.roomNumber;
}
public int getRoomCapacity() {
return this.capacity;
}
}
这个类包含一个构造函数,它接受房间 ID、房间号和房间容量。它还提供了获取房间属性的方法。
接下来,创建一个时隙类;该时间段代表一周中上课的日期和时间。
package chapter``5
public class Timeslot {
private final int timeslotId;
private final String timeslot;
public Timeslot(int timeslotId, String timeslot){
this.timeslotId = timeslotId;
this.timeslot = timeslot;
}
public int getTimeslotId(){
return this.timeslotId;
}
public String getTimeslot(){
return this.timeslot;
}
}
可以使用构造函数创建一个时隙,并将时隙 ID 和时隙细节作为一个字符串传递给它(细节可能看起来像“Mon 9:00–10:00”)。该类还包含获取对象属性的 getters。
第三个要设立的班级是教授班:
package chapter``5
public class Professor {
private final int professorId;
private final String professorName;
public Professor(int professorId, String professorName){
this.professorId = professorId;
this.professorName = professorName;
}
public int getProfessorId(){
return this.professorId;
}
public String getProfessorName(){
return this.professorName;
}
}
Professor 类包含一个接受教授 ID 和教授姓名的构造函数;它还包含获取教授属性的 getter 方法。
接下来,添加一个模块类来存储关于课程模块的信息。“模块”是一些人所谓的“课程”,如“微积分 101”或“美国历史 302”,像现实生活中的课程一样,可以有多个部分和学生群体在一周的不同时间与不同的教授一起学习课程。
package chapter``5
public class Module {
private final int moduleId;
private final String moduleCode;
private final String module;
private final int professorIds[];
public Module(int moduleId, String moduleCode, String module, int professorIds[]){
this.moduleId = moduleId;
this.moduleCode = moduleCode;
this.module = module;
this.professorIds = professorIds;
}
public int getModuleId(){
return this.moduleId;
}
public String getModuleCode(){
return this.moduleCode;
}
public String getModuleName(){
return this.module;
}
public int getRandomProfessorId(){
int professorId = professorIds[(int) (professorIds.length * Math.random())];
return professorId;
}
}
这个模块类包含一个构造函数,它接受模块 ID(数字)、模块代码(类似于“CS101”或“Hist302”)、模块名称和教授 ID 数组,教授 ID 数组可以教授模块。module 类还提供了 getter 方法——以及一个选择随机教授 ID 的方法。
下一个需要的类是 Group class 类,它保存关于学生组的信息。
package chapter``5
public class Group {
private final int groupId;
private final int groupSize;
private final int moduleIds[];
public Group(int groupId, int groupSize, int moduleIds[]){
this.groupId = groupId;
this.groupSize = groupSize;
this.moduleIds = moduleIds;
}
public int getGroupId(){
return this.groupId;
}
public int getGroupSize(){
return this.groupSize;
}
public int[] getModuleIds(){
return this.moduleIds;
}
}
group 类构造函数接受组 ID、组大小和组采用的模块 ID。它还提供了获取组信息的 getter 方法。
接下来,添加一个“Class”类。可以理解的是,本章中的术语可能会令人困惑——因此,大写的“class”指的是您将要创建的这个 Java 类,而我们将使用小写的“Class”来指代任何其他 Java 类。
Class 代表了以上所有的组合。它代表一个学生小组在特定的时间、特定的教室和特定的教授一起学习某个模块的某个部分。
package chapter``5
public class Class {
private final int classId;
private final int groupId;
private final int moduleId;
private int professorId;
private int timeslotId;
private int roomId;
public Class(int classId, int groupId, int moduleId) {
this.classId = classId;
this.moduleId = moduleId;
this.groupId = groupId;
}
public void addProfessor(int professorId) {
this.professorId = professorId;
}
public void addTimeslot(int timeslotId) {
this.timeslotId = timeslotId;
}
public void setRoomId(int roomId) {
this.roomId = roomId;
}
public int getClassId() {
return this.classId;
}
public int getGroupId() {
return this.groupId;
}
public int getModuleId() {
return this.moduleId;
}
public int getProfessorId() {
return this.professorId;
}
public int getTimeslotId() {
return this.timeslotId;
}
public int getRoomId() {
return this.roomId;
}
}
现在我们可以创建一个时间表类,将所有这些对象封装成一个时间表对象。到目前为止,时间表类是最重要的类,因为它是唯一理解不同约束应该如何相互作用的类。
Timetable 类还理解如何解析染色体,并创建一个候选时间表进行评估和评分。
package chapter``5
import java.util.HashMap;
public class Timetable {
private final HashMap<Integer, Room> rooms;
private final HashMap<Integer, Professor> professors;
private final HashMap<Integer, Module> modules;
private final HashMap<Integer, Group> groups;
private final HashMap<Integer, Timeslot> timeslots;
private Class classes[];
private int numClasses = 0;
/**
* Initialize new Timetable
*
*/
public Timetable() {
this.rooms = new HashMap<Integer, Room>();
this.professors = new HashMap<Integer, Professor>();
this.modules = new HashMap<Integer, Module>();
this.groups = new HashMap<Integer, Group>();
this.timeslots = new HashMap<Integer, Timeslot>();
}
public Timetable(Timetable cloneable) {
this.rooms = cloneable.getRooms();
this.professors = cloneable.getProfessors();
this.modules = cloneable.getModules();
this.groups = cloneable.getGroups();
this.timeslots = cloneable.getTimeslots();
}
private HashMap<Integer, Group> getGroups() {
return this.groups;
}
private HashMap<Integer, Timeslot> getTimeslots() {
return this.timeslots;
}
private HashMap<Integer, Module> getModules() {
return this.modules;
}
private HashMap<Integer, Professor> getProfessors() {
return this.professors;
}
/**
* Add new room
*
* @param roomId
* @param roomName
* @param capacity
*/
public void addRoom(int roomId, String roomName, int capacity) {
this.rooms.put(roomId, new Room(roomId, roomName, capacity));
}
/**
* Add new professor
*
* @param professorId
* @param professorName
*/
public void addProfessor(int professorId, String professorName) {
this.professors.put(professorId, new Professor(professorId, professorName));
}
/**
* Add new module
*
* @param moduleId
* @param moduleCode
* @param module
* @param professorIds
*/
public void addModule(int moduleId, String moduleCode, String module, int professorIds[]) {
this.modules.put(moduleId, new Module(moduleId, moduleCode, module, professorIds));
}
/**
* Add new group
*
* @param groupId
* @param groupSize
* @param moduleIds
*/
public void addGroup(int groupId, int groupSize, int moduleIds[]) {
this.groups.put(groupId, new Group(groupId, groupSize, moduleIds));
this.numClasses = 0;
}
/**
* Add new timeslot
*
* @param timeslotId
* @param timeslot
*/
public void addTimeslot(int timeslotId, String timeslot) {
this.timeslots.put(timeslotId, new Timeslot(timeslotId, timeslot));
}
/**
* Create classes using individual’s chromosome
*
* @param individual
*/
public void createClasses(Individual individual) {
// Init classes
Class classes[] = new Class[this.getNumClasses()];
// Get individual’s chromosome
int chromosome[] = individual.getChromosome();
int chromosomePos = 0;
int classIndex = 0;
for (Group group : this.getGroupsAsArray()) {
int moduleIds[] = group.getModuleIds();
for (int moduleId : moduleIds) {
classes[classIndex] = new Class(classIndex, group.getGroupId(), moduleId);
// Add timeslot
classes[classIndex].addTimeslot(chromosome[chromosomePos]);
chromosomePos++;
// Add room
classes[classIndex].setRoomId(chromosome[chromosomePos]);
chromosomePos++;
// Add professor
classes[classIndex].addProfessor(chromosome[chromosomePos]);
chromosomePos++;
classIndex++;
}
}
this.classes = classes;
}
/**
* Get room from roomId
*
* @param roomId
* @return room
*/
public Room getRoom(int roomId) {
if (!this.rooms.containsKey(roomId)) {
System.out.println("Rooms doesn’t contain key " + roomId);
}
return (Room) this.rooms.get(roomId);
}
public HashMap<Integer, Room> getRooms() {
return this.rooms;
}
/**
* Get random room
*
* @return room
*/
public Room getRandomRoom() {
Object[] roomsArray = this.rooms.values().toArray();
Room room = (Room) roomsArray[(int) (roomsArray.length * Math.random())];
return room;
}
/**
* Get professor from professorId
*
* @param professorId
* @return professor
*/
public Professor getProfessor(int professorId) {
return (Professor) this.professors.get(professorId);
}
/**
* Get module from moduleId
*
* @param moduleId
* @return module
*/
public Module getModule(int moduleId) {
return (Module) this.modules.get(moduleId);
}
/**
* Get moduleIds of student group
*
* @param groupId
* @return moduleId array
*/
public int[] getGroupModules(int groupId) {
Group group = (Group) this.groups.get(groupId);
return group.getModuleIds();
}
/**
* Get group from groupId
*
* @param groupId
* @return group
*/
public Group getGroup(int groupId) {
return (Group) this.groups.get(groupId);
}
/**
* Get all student groups
*
* @return array of groups
*/
public Group[] getGroupsAsArray() {
return (Group[]) this.groups.values().toArray(new Group[this.groups.size()]);
}
/**
* Get timeslot by timeslotId
*
* @param timeslotId
* @return timeslot
*/
public Timeslot getTimeslot(int timeslotId) {
return (Timeslot) this.timeslots.get(timeslotId);
}
/**
* Get random timeslotId
*
* @return timeslot
*/
public Timeslot getRandomTimeslot() {
Object[] timeslotArray = this.timeslots.values().toArray();
Timeslot timeslot = (Timeslot) timeslotArray[(int) (timeslotArray.length * Math.random())];
return timeslot;
}
/**
* Get classes
*
* @return classes
*/
public Class[] getClasses() {
return this.classes;
}
/**
* Get number of classes that need scheduling
*
* @return numClasses
*/
public int getNumClasses() {
if (this.numClasses > 0) {
return this.numClasses;
}
int numClasses = 0;
Group groups[] = (Group[]) this.groups.values().toArray(new Group[this.groups.size()]);
for (Group group : groups) {
numClasses += group.getModuleIds().length;
}
this.numClasses = numClasses;
return this.numClasses;
}
/**
* Calculate the number of clashes
*
* @return numClashes
*/
public int calcClashes() {
int clashes = 0;
for (Class classA : this.classes) {
// Check room capacity
int roomCapacity = this.getRoom(classA.getRoomId()).getRoomCapacity();
int groupSize = this.getGroup(classA.getGroupId()).getGroupSize();
if (roomCapacity < groupSize) {
clashes++;
}
// Check if room is taken
for (Class classB : this.classes) {
if (classA.getRoomId() == classB.getRoomId() && classA.getTimeslotId() == classB.getTimeslotId()
&& classA.getClassId() != classB.getClassId()) {
clashes++;
break;
}
}
// Check if professor is available
for (Class classB : this.classes) {
if (classA.getProfessorId() == classB.getProfessorId() && classA.getTimeslotId() == classB.getTimeslotId()
&& classA.getClassId() != classB.getClassId()) {
clashes++;
break;
}
}
}
return clashes;
}
}
这个类包含了在时间表中添加房间、时间段、教授、模块和组的方法。以这种方式,时间表类服务于双重目的:时间表对象知道所有可用的房间、时间段、教授等。,但是时间表对象也可以读取染色体,从该染色体创建类的子集,并帮助评估染色体的适合度。
请密切注意该类中的两个重要方法:createClasses 和 calcClashes。
createClasses 方法接受一个个体(即一个染色体),并利用它对必须安排的学生组和模块总数的了解,为这些组和模块创建许多类对象。然后,该方法开始读取染色体,并将可变信息(时间段、房间和教授)分配给这些类别中的每一个。因此,createClasses 方法确保每个模块和学生小组都被考虑在内,但它使用遗传算法和结果染色体来尝试不同的时间段、房间和教授组合。Timetable 类在本地缓存这些信息(作为“this.classes”)供以后使用。
一旦构建了类,calcClashes 方法依次检查每个类,并计算“冲突”的数量。在这种情况下,“冲突”是任何硬约束违反,例如教室太小的班级、与教室和时间段的冲突,或者与教授和时间段的冲突。遗传算法的计算方法稍后会使用冲突数。
高管阶层
我们现在可以创建一个包含程序的“main”方法的执行类。和前面的章节一样,我们将基于第二章中的伪代码构建这个类,用一些“TODO”注释代替我们将在本章中填写的实现细节。
首先,创建一个新的 Java 类,并将其命名为“TimetableGA”。确保它在“package chapter 5 中,并向其中添加以下代码:
package chapter``5
public class TimetableGA {
public static void main(String[] args) {
// TODO: Create Timetable and initialize with all the available courses, rooms, timeslots, professors, modules, and groups
// Initialize GA
GeneticAlgorithm ga = new GeneticAlgorithm(100, 0.01, 0.9, 2, 5);
// TODO: Initialize population
// TODO: Evaluate population
// Keep track of current generation
int generation = 1;
// Start evolution loop
// TODO: Add termination condition
while (false) {
// Print fitness
System.out.println("G" + generation + " Best fitness: " + population.getFittest(0).getFitness());
// Apply crossover
population = ga.crossoverPopulation(population);
// TODO: Apply mutation
// TODO: Evaluate population
// Increment the current generation
generation++;
}
// TODO: Print final fitness
// TODO: Print final timetable
}
}
为了完成这一章,我们给了自己八个待办事项。请注意,交叉不是一个待办事项,我们将重复使用第三章中的锦标赛选择和第二章中的统一交叉。
第一个 TODO 很容易解决,我们现在就做。一般来说,学校课程表的信息来自数据库,但是现在让我们对一些班级和教授进行硬编码。由于下面的代码有点长,让我们在 TimetableGA 类中为它创建一个单独的方法。将此方法添加到您喜欢的任何位置:
private static Timetable initializeTimetable() {
// Create timetable
Timetable timetable = new Timetable();
// Set up rooms
timetable.addRoom(1, "A1", 15);
timetable.addRoom(2, "B1", 30);
timetable.addRoom(4, "D1", 20);
timetable.addRoom(5, "F1", 25);
// Set up timeslots
timetable.addTimeslot(1, "Mon 9:00 - 11:00");
timetable.addTimeslot(2, "Mon 11:00 - 13:00");
timetable.addTimeslot(3, "Mon 13:00 - 15:00");
timetable.addTimeslot(4, "Tue 9:00 - 11:00");
timetable.addTimeslot(5, "Tue 11:00 - 13:00");
timetable.addTimeslot(6, "Tue 13:00 - 15:00");
timetable.addTimeslot(7, "Wed 9:00 - 11:00");
timetable.addTimeslot(8, "Wed 11:00 - 13:00");
timetable.addTimeslot(9, "Wed 13:00 - 15:00");
timetable.addTimeslot(10, "Thu 9:00 - 11:00");
timetable.addTimeslot(11, "Thu 11:00 - 13:00");
timetable.addTimeslot(12, "Thu 13:00 - 15:00");
timetable.addTimeslot(13, "Fri 9:00 - 11:00");
timetable.addTimeslot(14, "Fri 11:00 - 13:00");
timetable.addTimeslot(15, "Fri 13:00 - 15:00");
// Set up professors
timetable.addProfessor(1, "Dr P Smith");
timetable.addProfessor(2, "Mrs E Mitchell");
timetable.addProfessor(3, "Dr R Williams");
timetable.addProfessor(4, "Mr A Thompson");
// Set up modules and define the professors that teach them
timetable.addModule(1, "cs1", "Computer Science", new int[] { 1, 2 });
timetable.addModule(2, "en1", "English", new int[] { 1, 3 });
timetable.addModule(3, "ma1", "Maths", new int[] { 1, 2 });
timetable.addModule(4, "ph1", "Physics", new int[] { 3, 4 });
timetable.addModule(5, "hi1", "History", new int[] { 4 });
timetable.addModule(6, "dr1", "Drama", new int[] { 1, 4 });
// Set up student groups and the modules they take.
timetable.addGroup(1, 10, new int[] { 1, 3, 4 });
timetable.addGroup(2, 30, new int[] { 2, 3, 5, 6 });
timetable.addGroup(3, 18, new int[] { 3, 4, 5 });
timetable.addGroup(4, 25, new int[] { 1, 4 });
timetable.addGroup(5, 20, new int[] { 2, 3, 5 });
timetable.addGroup(6, 22, new int[] { 1, 4, 5 });
timetable.addGroup(7, 16, new int[] { 1, 3 });
timetable.addGroup(8, 18, new int[] { 2, 6 });
timetable.addGroup(9, 24, new int[] { 1, 6 });
timetable.addGroup(10, 25, new int[] { 3, 4 });
return timetable;
}
现在,将 main 方法顶部的第一个 TODO 替换为以下内容:
// Get a Timetable object with all the available information.
Timetable timetable = initializeTimetable();
main 方法的顶部现在应该看起来像这样:
public class TimetableGA {
public static void main(String[] args) {
// Get a Timetable object with all the available information.
Timetable timetable = initializeTimetable();
// Initialize GA ... (and the rest of the class, unchanged from before!)
GeneticAlgorithm ga = new GeneticAlgorithm(100, 0.01, 0.9, 2, 5);
这为我们提供了一个包含所有必要信息的时间表实例,我们创建的 GeneticAlgorithm 对象类似于前面章节中的对象:一个人口为 100 的遗传算法,变异率为 0.01,交叉率为 0.9,2 个精英个体,锦标赛规模为 5。
我们现在还有七个 TODOs。下一个待办事项与初始化填充相关。为了创造一个群体,我们需要知道我们需要的染色体的长度;这是由时间表中小组和模块的数量决定的。
我们需要能够从一个时间表对象初始化一个群体,这意味着我们也需要能够从一个时间表对象初始化一个个体。因此,为了解决这个 TODO,我们必须做三件事:向 GeneticAlgorithm 类添加 initPopulation(Timetable)方法,向接受时间表的群体添加构造函数,向接受时间表的个体添加构造函数。
让我们从底层开始,一步一步往上走。通过添加新的构造函数来更新单个类,该构造函数根据时间表构建单个类。构造函数使用时间表对象来确定必须安排的课程数量,这决定了染色体的长度。染色体本身是通过从时间表中随机抽取房间、时间段和教授来构建的。
将下面的方法添加到单个类中的任意位置:
public Individual(Timetable timetable) {
int numClasses = timetable.getNumClasses();
// 1 gene for room, 1 for time, 1 for professor
int chromosomeLength = numClasses * 3;
// Create random individual
int newChromosome[] = new int[chromosomeLength];
int chromosomeIndex = 0;
// Loop through groups
for (Group group : timetable.getGroupsAsArray()) {
// Loop through modules
for (int moduleId : group.getModuleIds()) {
// Add random time
int timeslotId = timetable.getRandomTimeslot().getTimeslotId();
newChromosome[chromosomeIndex] = timeslotId;
chromosomeIndex++;
// Add random room
int roomId = timetable.getRandomRoom().getRoomId();
newChromosome[chromosomeIndex] = roomId;
chromosomeIndex++;
// Add random professor
Module module = timetable.getModule(moduleId);
newChromosome[chromosomeIndex] = module.getRandomProfessorId();
chromosomeIndex++;
}
}
this.chromosome = newChromosome;
}
这个构造函数接受一个时间表对象,并遍历每个学生组和该组注册的每个模块(给出需要安排的班级总数)。对于每个班级,随机选择一个教室、教授和时间段,并将相应的 ID 添加到染色体中。
接下来,将这个构造函数方法添加到 Population 类中。这个构造函数通过简单地调用我们刚刚创建的个体构造函数,从用时间表初始化的个体中构建一个群体。
public Population(int populationSize, Timetable timetable) {
// Initial population
this.population = new Individual[populationSize];
// Loop over population size
for (int individualCount = 0; individualCount < populationSize; individualCount++) {
// Create individual
Individual individual = new Individual(timetable);
// Add individual to population
this.population[individualCount] = individual;
}
}
接下来,在 GeneticAlgorithm 类中重新实现 initPopulation 方法,以使用新的 Population 构造函数:
public Population initPopulation(Timetable timetable) {
// Initialize population
Population population = new Population(this.populationSize, timetable);
return population;
}
我们最终可以解析下一个 TODO:替换 executive 类的 main 方法中的“TODO: Initialize Population ”,并调用 GeneticAlgorithm 的 initPopulation 方法:
// Initialize population
Population population = ga.initPopulation(timetable);
executive TimetableGA 类的主要方法现在应该如下所示。由于我们还没有实现终止条件,这段代码还不会做任何有趣的事情,事实上 Java 编译器可能会抱怨循环内无法到达的代码。我们会尽快解决这个问题。
public static void main(String[] args) {
// Get a Timetable object with all the available information.
Timetable timetable = initializeTimetable();
// Initialize GA
GeneticAlgorithm ga = new GeneticAlgorithm(100, 0.01, 0.9, 2, 5);
// Initialize population
Population population = ga.initPopulation(timetable);
// TODO: Evaluate population
// Keep track of current generation
int generation = 1;
// Start evolution loop
// TODO: Add termination condition
while (false) {
// Print fitness
System.out.println("G" + generation + " Best fitness: " + population.getFittest(0).getFitness());
// Apply crossover
population = ga.crossoverPopulation(population);
// TODO: Apply mutation
// TODO: Evaluate population
// Increment the current generation
generation++;
}
// TODO: Print final fitness
// TODO: Print final timetable
}
估价
我们的初始种群已经创建,我们需要评估这些个体,并为它们分配适合度值。从前面我们知道,我们的目标是优化我们的课表,以避免打破尽可能多的限制。这意味着个人的适应值将与违反多少约束成反比。
打开并检查时间表类的“createClasses”方法。利用它对需要在特定时间安排到有教授的教室的所有组和模块的了解,它将一个染色体转换成一组类对象,并将它们藏起来以供评估。这个方法不做任何实际的评估,但是它是一个染色体和评估步骤之间的桥梁。
接下来,检查同一个类中的“calcClashes”方法。这种方法将每个班级与其他班级进行比较,如果违反了任何硬约束,例如:如果选定的房间太小,如果该房间的时间安排有冲突,或者如果教授的时间安排有冲突,则添加一个“冲突”。该方法返回它找到的冲突总数。
现在,我们已经准备好创建我们的适应度函数,并最终评估人口中个体的适应度。
打开 GeneticAlgorithm 类,首先添加以下 calcFitness 方法。
public double calcFitness(Individual individual, Timetable timetable) {
// Create new timetable object to use -- cloned from an existing timetable
Timetable threadTimetable = new Timetable(timetable);
threadTimetable.createClasses(individual);
// Calculate fitness
int clashes = threadTimetable.calcClashes();
double fitness = 1 / (double) (clashes + 1);
individual.setFitness(fitness);
return fitness;
}
calcFitness 方法克隆给它的时间表对象,调用 createClasses 方法,然后通过 calcClashes 方法计算冲突的数量。适应度定义为碰撞数的倒数-0 碰撞将导致适应度为 1。
也向 GeneticAlgorithm 类添加一个 evalPopulation 方法。和前面的章节一样,这个方法简单地遍历所有的样本,并为每个样本调用 calcFitness。
public void evalPopulation(Population population, Timetable timetable) {
double populationFitness = 0;
// Loop over population evaluating individuals and summing population
// fitness
for (Individual individual : population.getIndividuals()) {
populationFitness += this.calcFitness(individual, timetable);
}
population.setPopulationFitness(populationFitness);
}
最后,我们可以在 executive TimetableGA 类的 main 方法中评估总体并解决一些 TODOs。更新具有“TODO:评估人口”的两个位置,改为显示:
// Evaluate population
ga.evalPopulation(population, timetable);
此时,应该还有四个 TODOs。此时程序仍然不可运行,因为终止条件尚未定义,循环尚未启用。
结束
构建类调度器的下一步是设置终止检查。以前,我们使用世代数和适应度来决定是否要终止我们的遗传算法。这一次,我们将结合这两个终止条件,或者在经过一定数量的代之后,或者如果它找到了有效的解决方案,就终止我们的遗传算法。
因为适应值是基于破坏的约束的数量,所以我们知道完美的解决方案将具有 1 的适应值。保持前面的终止检查不变,并将第二个终止检查添加到 GeneticAlgorithm 类中。我们将在执行循环中使用这两种检查。
public boolean isTerminationConditionMet(Population population) {
return population.getFittest(0).getFitness() == 1.0;
}
此时,确认第二个 isTerminationConditionMet 方法(应该已经在 GeneticAlgorithm 类中)如下所示:
public boolean isTerminationConditionMet(int generationsCount, int maxGenerations) {
return (generationsCount > maxGenerations);
}
现在,我们可以将两个终止检查添加到我们的 main 方法中,并启用演化循环。打开 executive TimetableGA 类,并按如下方式解决“TODO:Add termination condition”TODO:
// Start evolution loop
while (ga.isTerminationConditionMet(generation, 1000) == false
&& ga.isTerminationConditionMet(population) == false) {
// Rest of the loop in here...
第一个 isTerminationConditionMet 调用将我们限制在 1,000 代,而第二个调用检查在群体中是否有任何适合度为 1 的个体。
让我们快速解决另外两个 TODOs。当循环结束时,我们有一些简单的报告要呈现。删除循环后的两个 TODOs(“打印最终健身”和“打印最终时间表”),替换为以下内容:
// Print fitness
timetable.createClasses(population.getFittest(0));
System.out.println();
System.out.println("Solution found in " + generation + " generations");
System.out.println("Final solution fitness: " + population.getFittest(0).getFitness());
System.out.println("Clashes: " + timetable.calcClashes());
// Print classes
System.out.println();
Class classes[] = timetable.getClasses();
int classIndex = 1;
for (Class bestClass : classes) {
System.out.println("Class " + classIndex + ":");
System.out.println("Module: " +
timetable.getModule(bestClass.getModuleId()).getModuleName());
System.out.println("Group: " +
timetable.getGroup(bestClass.getGroupId()).getGroupId());
System.out.println("Room: " +
timetable.getRoom(bestClass.getRoomId()).getRoomNumber());
System.out.println("Professor: " +
timetable.getProfessor(bestClass.getProfessorId()).getProfessorName());
System.out.println("Time: " +
timetable.getTimeslot(bestClass.getTimeslotId()).getTimeslot());
System.out.println("-----");
classIndex++;
}
此时,您应该能够运行程序,观察进化循环,并得到一个结果。没有突变,你可能永远找不到解决方案,但是我们从第二章和第三章中重新利用的现有交叉方法通常足以找到解决方案。然而,如果你运行这个程序很多次,但在不到 1000 代的时间里,你从来没有找到一个解决方案,你可能要重新阅读这一章,并确保你没有犯任何错误。
我们把熟悉的“交叉”部分从本章中去掉,因为这里没有新的技术。回想一下第二章中的均匀交叉,随机选择染色体并与父代交换,而不保留基因组内的任何连续性。对于这个问题,这是一个很好的方法,因为在这种情况下,基因组(代表教授、房间和时间段的组合)更有可能是有害的,而不是有益的。
变化
回想一下,染色体上的约束通常决定了人们为遗传算法选择的突变和交叉技术。在这种情况下,染色体由特定的房间、教授和时间段 id 组成;我们不能简单地选择随机数。此外,由于房间、教授和时间段都有不同的 id 范围,我们也不能简单地在 1 和“X”之间选择一个随机数。潜在地,我们可以为我们正在编码的每一种不同类型的对象(房间、教授和时间段)选择随机数,但这也假设 id 是连续的,它们可能不是!
我们可以从均匀交叉中得到一个提示来解决我们的变异问题。在均匀交叉中,从现有的有效亲本中随机选择基因。父母可能不是群体中最合适的个体,但至少是有效的。
突变可以以类似的方式实现。我们可以创建一个新的随机但有效的个体,并在本质上运行均匀交叉来实现变异,而不是为染色体中的随机基因选择一个随机数!也就是我们可以用我们的个体(时间表)构造器创造一个全新的随机个体,然后从随机个体中选择基因复制到要变异的个体中。这种技术被称为统一突变,它确保我们所有突变的个体都是完全有效的,永远不会选择没有意义的基因。在 GeneticAlgorithm 类中的任意位置添加以下方法:
public Population mutatePopulation(Population population, Timetable timetable) {
// 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);
// Create random individual to swap genes with
Individual randomIndividual = new Individual(timetable);
// 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()) {
// Swap for new gene
individual.setGene(geneIndex, randomIndividual.getGene(geneIndex));
}
}
}
// Add individual to population
newPopulation.setIndividual(populationIndex, individual);
}
// Return mutated population
return newPopulation;
}
在这种方法中,像前几章中的突变一样,群体通过在群体中的非精英个体上循环来突变。与其他倾向于直接修改基因的突变技术不同,这种突变算法创建一个随机但有效的个体,并从中随机复制基因。
我们现在可以在执行类的 main 方法中解析最终的 TODO。将这个一行程序添加到主循环中:
// Apply mutation
population = ga.mutatePopulation(population, timetable);
我们现在应该一切就绪,可以运行我们的遗传算法,并创建一个新的大学时间表。如果您的 Java IDE 显示错误,或者如果它此时不能编译,请回顾本章并解决您发现的任何问题。
执行
确保您的 TimetableGA 类如下所示:
package chapter``5
public class TimetableGA {
public static void main(String[] args) {
// Get a Timetable object with all the available information.
Timetable timetable = initializeTimetable();
// Initialize GA
GeneticAlgorithm ga = new GeneticAlgorithm(100, 0.01, 0.9, 2, 5);
// Initialize population
Population population = ga.initPopulation(timetable);
// Evaluate population
ga.evalPopulation(population, timetable);
// Keep track of current generation
int generation = 1;
// Start evolution loop
while (ga.isTerminationConditionMet(generation, 1000) == false
&& ga.isTerminationConditionMet(population) == false) {
// Print fitness
System.out.println("G" + generation + " Best fitness: " + population.getFittest(0).getFitness());
// Apply crossover
population = ga.crossoverPopulation(population);
// Apply mutation
population = ga.mutatePopulation(population, timetable);
// Evaluate population
ga.evalPopulation(population, timetable);
// Increment the current generation
generation++;
}
// Print fitness
timetable.createClasses(population.getFittest(0));
System.out.println();
System.out.println("Solution found in " + generation + " generations");
System.out.println("Final solution fitness: " + population.getFittest(0).getFitness());
System.out.println("Clashes: " + timetable.calcClashes());
// Print classes
System.out.println();
Class classes[] = timetable.getClasses();
int classIndex = 1;
for (Class bestClass : classes) {
System.out.println("Class " + classIndex + ":");
System.out.println("Module: " +
timetable.getModule(bestClass.getModuleId()).getModuleName());
System.out.println("Group: " +
timetable.getGroup(bestClass.getGroupId()).getGroupId());
System.out.println("Room: " +
timetable.getRoom(bestClass.getRoomId()).getRoomNumber());
System.out.println("Professor: " +
timetable.getProfessor(bestClass.getProfessorId()).getProfessorName());
System.out.println("Time: " +
timetable.getTimeslot(bestClass.getTimeslotId()).getTimeslot());
System.out.println("-----");
classIndex++;
}
}
/**
* Creates a Timetable with all the necessary course information.
* @return
*/
private static Timetable initializeTimetable() {
// Create timetable
Timetable timetable = new Timetable();
// Set up rooms
timetable.addRoom(1, "A1", 15);
timetable.addRoom(2, "B1", 30);
timetable.addRoom(4, "D1", 20);
timetable.addRoom(5, "F1", 25);
// Set up timeslots
timetable.addTimeslot(1, "Mon 9:00 - 11:00");
timetable.addTimeslot(2, "Mon 11:00 - 13:00");
timetable.addTimeslot(3, "Mon 13:00 - 15:00");
timetable.addTimeslot(4, "Tue 9:00 - 11:00");
timetable.addTimeslot(5, "Tue 11:00 - 13:00");
timetable.addTimeslot(6, "Tue 13:00 - 15:00");
timetable.addTimeslot(7, "Wed 9:00 - 11:00");
timetable.addTimeslot(8, "Wed 11:00 - 13:00");
timetable.addTimeslot(9, "Wed 13:00 - 15:00");
timetable.addTimeslot(10, "Thu 9:00 - 11:00");
timetable.addTimeslot(11, "Thu 11:00 - 13:00");
timetable.addTimeslot(12, "Thu 13:00 - 15:00");
timetable.addTimeslot(13, "Fri 9:00 - 11:00");
timetable.addTimeslot(14, "Fri 11:00 - 13:00");
timetable.addTimeslot(15, "Fri 13:00 - 15:00");
// Set up professors
timetable.addProfessor(1, "Dr P Smith");
timetable.addProfessor(2, "Mrs E Mitchell");
timetable.addProfessor(3, "Dr R Williams");
timetable.addProfessor(4, "Mr A Thompson");
// Set up modules and define the professors that teach them
timetable.addModule(1, "cs1", "Computer Science", new int[] { 1, 2 });
timetable.addModule(2, "en1", "English", new int[] { 1, 3 });
timetable.addModule(3, "ma1", "Maths", new int[] { 1, 2 });
timetable.addModule(4, "ph1", "Physics", new int[] { 3, 4 });
timetable.addModule(5, "hi1", "History", new int[] { 4 });
timetable.addModule(6, "dr1", "Drama", new int[] { 1, 4 });
// Set up student groups and the modules they take.
timetable.addGroup(1, 10, new int[] { 1, 3, 4 });
timetable.addGroup(2, 30, new int[] { 2, 3, 5, 6 });
timetable.addGroup(3, 18, new int[] { 3, 4, 5 });
timetable.addGroup(4, 25, new int[] { 1, 4 });
timetable.addGroup(5, 20, new int[] { 2, 3, 5 });
timetable.addGroup(6, 22, new int[] { 1, 4, 5 });
timetable.addGroup(7, 16, new int[] { 1, 3 });
timetable.addGroup(8, 18, new int[] { 2, 6 });
timetable.addGroup(9, 24, new int[] { 1, 6 });
timetable.addGroup(10, 25, new int[] { 3, 4 });
return timetable;
}
}
按原样运行类调度器应该生成大约 50 代的解决方案,并且在所有情况下应该呈现零冲突(硬约束)的解决方案。如果您的算法反复达到 1,000 代的限制,或者如果它提供了有冲突的解决方案,那么您的实现可能有问题!
花一分钟时间直观地检查算法返回的时间表结果。确认教授、房间和时间段之间没有实际冲突。
此时,您可能还想尝试在 TimetableGA 的“initializeTimetable”方法中为时间表初始化添加更多教授、模块、时隙、组和房间。能不能强制算法失效?
分析和提炼
排课问题是一个很好的例子,它使用遗传算法在解空间中搜索有效解,而不是最优解。这个问题可以有许多适合度为 1 的解,我们所要做的就是找到这些有效解中的一个。当只考虑硬约束时,任何两个有效的解决方案之间没有真正的区别,我们可以简单地选择我们找到的第一个解决方案。
与第四章中的旅行推销员问题不同,排课问题的这一特性意味着算法实际上可以返回无效解。旅行推销员问题中的一个解决方案如果没有访问每个城市一次就可能是无效的,但是因为我们非常小心地设计了我们的初始化、交叉和变异算法,所以使用来自第四章的代码,我们不会遇到无效的解决方案。我们的 TSP 求解器返回的所有路径都是有效的,这只是一个寻找最短可能路径的问题。如果我们在任何一代中的任何一点停止 TSP 算法,并随机选择一个群体成员,这将是一个有效的解决方案。
然而,在这一章中,大多数解决方案都是无效的,我们只有在找到第一个有效的解决方案或时间用完时才停下来。这两个问题的区别如下:在旅行推销员问题中,很容易创建一个有效的解决方案(只要确保每个城市都被访问一次;但是,不能保证解决方案的适用性!),但是在班级调度器中,创建有效的解决方案是困难的部分。
此外,如果没有任何软约束,由类调度器返回的任何两个有效解之间的适合度没有差别。在这种情况下,硬约束决定解决方案是否有效,而软约束决定解决方案的质量。上面的实现并不偏好任何特定的有效解决方案,因为它无法确定解决方案的质量——它只知道解决方案是否有效。
向类调度器添加软约束会显著改变这个问题。我们不再只是寻找任何有效的解决方案,而是想要最好的有效解决方案。
幸运的是,遗传算法特别擅长这种类型的约束杂耍。事实上,一个人只由一个单一的数字来判断——它的适应性——这对我们有利。决定个体适应度的算法对遗传算法来说是完全不透明的——就遗传算法而言,这是一个黑箱。虽然适应值对于遗传算法非常重要,不能随意实现,但它的简单性和不透明性也让我们可以用它来协调各种约束和条件。因为一切都可以归结为一个无量纲的适应度分数,所以我们能够缩放和转换尽可能多的约束,并且该约束的重要性由它对适应度分数的贡献程度来表示。
上面实现的类调度器仅使用硬约束,并将适合度分数限制在 0-1 的范围内。当组合不同类型的约束时,应该确保硬约束对适应性分数具有压倒性的影响,而软约束做出更适度的贡献。
例如,假设您需要向类调度器添加一些软约束,每个软约束的重要性略有不同。当然,硬约束仍然适用。你如何调和软约束和硬约束?现有的适应度分数“1 /(冲突+ 1)”显然不包含软约束,即使它将破坏的软约束视为“冲突”,仍会将它们与硬约束置于同等地位。在该模型下,有可能选择一个无效的解决方案,因为它可能有许多满足的软约束,这些软约束弥补了由于硬约束被破坏而导致的适应性损失。
相反,考虑一个新的适应度评分系统:每个打破的硬约束从适应度分数中减去 100,而任何满足的软约束可能根据其重要性给适应度分数增加 1、2 或 3 分。在这个方案下,我们应该只考虑得分为零或以上的解决方案,因为任何负值都有一个破坏的硬约束。该方案还确保了一个被破坏的硬约束不可能被大量满足的软约束抵消——一个硬约束对适应性分数的贡献如此巨大,以至于软约束不可能弥补一个被破坏的硬约束所扣掉的 100 分。最后,该方案还允许您对软约束进行优先级排序——越重要的约束对适应度分数的贡献越大。
为了进一步说明适合度分数归一化约束的思想;考虑任何地图和方向工具(如谷歌地图)。当您搜索两个位置之间的方向时,健身分数的主要贡献者是从一个地方到另一个地方所需的时间。一个简单的算法可能使用以分钟为单位的旅行时间作为其适应度得分(在这种情况下,我们将它称为“成本得分”,因为越低越好,而适应度的倒数通常称为“成本”)。
花费 60 分钟的路线比花费 70 分钟的路线要好——但是我们知道现实生活中并不总是这样。也许更短的路线有 20 美元的高昂费用。用户可以选择“避免通行费”选项,现在算法必须协调驾驶分钟数和通行费。一美元值多少分钟?如果你决定每一美元给成本分数加一分,那么较短的路线现在的成本是 80,输给了较长但更便宜的路线。另一方面,如果你减少了避免通行费的权重,并决定 1 美元的通行费只给路线增加了 0.25 英镑的成本,则较短的路线仍将以 65 英镑的成本获胜。
最后,当在遗传算法中处理硬约束和软约束时,一定要理解适应度分数代表什么,以及每个约束将如何影响个人的分数。
练习
Add soft constraints to the class scheduler. This could include preferred professor time and preferred classrooms. Implement support for a config file or database connection to add initial timetable data. Build a class scheduler for a school timetable that requires students to have a class scheduled for each period.
摘要
在这一章中,我们已经讲述了使用遗传算法安排课程的基础知识。我们没有使用遗传算法来寻找最优解,而是使用遗传算法来寻找满足许多硬约束的第一个有效解。
我们还探索了一种新的突变策略,确保突变的染色体仍然有效。我们没有直接修改染色体并增加随机性——在这种情况下,这可能导致无效的染色体——而是创建了一个已知有效的随机个体,并以一种类似于均匀交叉的方式与其交换基因。该算法仍然被认为是一致变异,但是本章中使用的新方法使得确保有效变异变得更加容易。
我们还结合了第二章的的统一交叉和第三章的的锦标赛选择,展示了遗传算法的许多方面是模块化的、独立的,并且能够以不同的方式组合。
最后,我们讨论了遗传算法中适应值的灵活性。我们了解到,无量纲适合度分数可用于引入软约束,并使它们与硬约束相协调,最终目标是不仅产生有效的结果,还产生高质量的结果。
六、最优化
在这一章中,我们将探索常用于优化遗传算法的不同技术。随着所解决的问题变得越来越复杂,额外的优化技术变得越来越重要。一个优化良好的算法在解决较大问题时可以节省数小时甚至数天;因此,当问题达到一定的复杂程度时,优化技术是必不可少的。
除了探索一些常见的优化技术之外,本章还将介绍一些使用前几章案例研究中的遗传算法的实现示例。
自适应遗传算法
自适应遗传算法(AGA)是遗传算法的一个流行子集,当在正确的情况下使用时,它可以提供比标准实现显著的性能改进。正如我们在前面的章节中了解到的,决定遗传算法性能的一个关键因素是其参数的配置方式。我们已经讨论了在构建有效的遗传算法时,找到正确的变异率和交叉率值的重要性。典型地,在最终达到令人满意的配置之前,配置参数将需要一些试验和错误,以及一些直觉。自适应遗传算法是有用的,因为它们可以通过基于算法的状态调整这些参数来帮助自动调整这些参数。这些参数调整发生在遗传算法运行时,希望在执行过程中的任何特定时间使用最佳参数。正是这种算法参数的连续自适应调整通常会导致遗传算法的性能提高。
自适应遗传算法使用诸如平均群体适应度和群体的当前最佳适应度之类的信息,以最适合其当前状态的方式来计算和更新其参数。例如,通过将任何特定的个体与群体中当前最健康的个体进行比较,可以衡量该个体相对于当前最佳个体的表现如何。通常,我们希望增加保留表现良好的个人的机会,减少保留表现不佳的个人的机会。我们可以做到这一点的一个方法是允许算法自适应地更新变异率。
不幸的是,事情没那么简单。过一会儿,群体将开始收敛,个体将开始向搜索空间中的单个点靠拢。当这种情况发生时,搜索的进程可能会停止,因为个体之间的差异很小。在这种情况下,稍微提高变异率,鼓励在搜索空间内搜索替代区域是有效的。
我们可以通过计算当前最佳适应度和平均群体适应度之间的差异来确定算法是否已经开始收敛。当平均群体适应度接近当前最佳适应度时,我们知道群体已经开始在搜索空间的小区域周围收敛。
然而,自适应遗传算法可用于调整的不仅仅是突变率。可以应用类似的技术来调整遗传算法的其他参数,例如交叉率,以根据需要提供进一步的改进。
履行
正如许多与遗传算法有关的事情一样,更新参数的最佳方式通常需要一些实验。我们将探索一种更常见的方法,如果您愿意,您可以自己尝试其他方法。
如前所述,当计算任何给定个体的突变率时,要考虑的两个最重要的特征是当前个体的表现和整个群体的整体表现。我们将用于评估这两个特征并更新突变率的算法如下:
pm=(fmax-fI)/(fmax–favg)* m,fIfavg
p m = m,f i ≤ f avg
当个体的适应度大于群体的平均适应度时,我们从群体中选取最佳适应度(f max )并找出当前个体适应度(f i )之间的差异。然后我们找到最大群体适应度和平均群体适应度之间的差(f avg )并将这两个值相除。我们可以使用这个值来调整初始化时设置的突变率。如果个体的适应度等于或小于群体的平均适应度,我们简单地使用初始化时设置的突变率。
为了使事情变得简单,我们可以将新的自适应遗传算法代码实现到我们以前的类调度器代码中。首先,我们需要添加一种新的方法来获得群体的平均适应度。我们可以通过在文件中的任意位置向 Population 类添加以下方法来实现这一点:
/**
* Get average fitness
*
* @return The average individual fitness
*/
public double getAvgFitness(){
if (this.populationFitness == -1) {
double totalFitness = 0;
for (Individual individual : population) {
totalFitness += individual.getFitness();
}
this.populationFitness = totalFitness;
}
return populationFitness / this.size();
}
现在,我们可以通过更新变异函数来使用我们的自适应变异算法来完成实现,
/**
* Apply mutation to population
*
* @param population
* @param timetable
* @return The mutated population
*/
public Population mutatePopulation(Population population, Timetable timetable){
// Initialize new population
Population newPopulation = new Population(this.populationSize);
// Get best fitness
double bestFitness = population.getFittest(0).getFitness();
// Loop over current population by fitness
for (int populationIndex = 0; populationIndex < population.size(); populationIndex++) {
Individual individual = population.getFittest(populationIndex);
// Create random individual to swap genes with
Individual randomIndividual = new Individual(timetable);
// Calculate adaptive mutation rate
double adaptiveMutationRate = this.mutationRate;
if (individual.getFitness() > population.getAvgFitness()) {
double fitnessDelta1 = bestFitness - individual.getFitness();
double fitnessDelta2 = bestFitness - population.getAvgFitness();
adaptiveMutationRate = (fitnessDelta1 / fitnessDelta2) * this.mutationRate;
}
// 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 mutating?
if (adaptiveMutationRate > Math.random()) {
// Swap for new gene
individual.setGene(geneIndex, randomIndividual.getGene(geneIndex));
}
}
}
// Add individual to population
newPopulation.setIndividual(populationIndex, individual);
}
// Return mutated population
return newPopulation;
}
这个新的 mutatePopulation 方法除了实现上述算法的自适应变异代码之外,与原来的方法相同。
当在启用自适应变异的情况下初始化遗传算法时,所使用的变异率现在将是最大可能的变异率,并且将根据当前个体和群体整体的适应度按比例缩小。正因为如此,较高的初始突变率可能是有益的。
练习
Use what you know about the adaptive mutation rate to implement an adaptive crossover rate into your genetic algorithm.
多重启发式
当谈到优化遗传算法时,实现二次启发式是在某些条件下实现显著性能改进的另一种常见方法。在遗传算法中实现第二种启发式算法允许我们将多种启发式方法的最佳方面结合到一种算法中,提供对搜索策略和性能的进一步控制。
两种常用于遗传算法的启发式算法是模拟退火和禁忌搜索。模拟退火是一种模拟冶金中退火过程的启发式搜索。简而言之,它是一种爬山算法,旨在逐渐降低接受更差解决方案的比率。在遗传算法的背景下,模拟退火将随着时间的推移降低突变率和/或交叉率。
另一方面,禁忌搜索是一种搜索算法,它保持“禁忌”(源自“禁忌”)解决方案的列表,以防止算法返回到搜索空间中已知为薄弱的先前访问过的区域。这个禁忌列表有助于算法避免重复考虑它以前找到的已知薄弱的解决方案。
通常情况下,多启发式方法只会在包含它可以给搜索过程带来某些需要的改进的情况下实施。例如,如果遗传算法在搜索空间的某个区域收敛得太快,在算法中实现模拟退火可能有助于控制算法收敛的速度。
履行
让我们通过结合模拟退火算法和遗传算法来看一个多启发式算法的快速例子。如前所述,模拟退火算法是一种爬山算法,它最初以较高的速度接受较差的解决方案;然后,随着算法的运行,它会逐渐降低接受更差解决方案的比率。
将该特性实现到遗传算法中的最简单的方法之一是通过更新变异和交叉率,以高速率开始,然后随着算法的进展逐渐降低变异和交叉率。这种初始的高变异和交叉率将导致遗传算法搜索大面积的搜索空间。然后,随着变异和交叉率缓慢降低,遗传算法应该开始将其搜索集中在适应值较高的搜索空间区域。
为了改变变异和交叉概率,我们使用一个温度变量,该变量在算法运行时开始较高或“热”,然后慢慢降低或“冷”。这种加热和冷却技术直接受到冶金中退火工艺的启发。在每一代之后,温度稍微降低,这降低了突变和交叉的概率。
为了开始实现,我们需要在 GeneticAlgorithm 类中创建两个新变量。冷却速率应该设置为一个很小的分数,通常在 0.001 或更小的数量级,尽管这个数字将取决于您期望运行的代数以及您希望模拟退火有多激进。
private double temperature = 1.0;
private double coolingRate;
接下来,我们需要创建一个函数来根据冷却速率冷却温度。
/**
* Cool temperature
*/
public void coolTemperature() {
this.temperature *= (1 - this.coolingRate);
}
现在,我们可以更新变异函数,以便在决定是否应用变异时考虑温度变量。我们可以通过修改这行代码来做到这一点,
// Does this gene need mutation?
if (this.mutationRate > Math.random()) {
为了现在包括新的温度变量,
// Does this gene need mutation?
if ((this.mutationRate * this.getTempature()) > Math.random()) {
为了完成这一步,在执行类的“main”方法中更新遗传算法的循环代码,以便在每一代结束时运行 coolTemperature()函数。同样,您可能需要调整初始突变率,因为它现在将根据温度值作为最大速率。
练习
Use what you know about the simulated annealing heuristic to apply it to crossover rate.
性能提升
除了改进搜索试探法,还有其他方法来优化遗传算法。优化遗传算法最有效的方法之一可能就是简单地编写高效的代码。当构建需要运行成千上万代的遗传算法时,只需将每代的处理时间缩短几分之一秒,就可以大大减少总体运行时间。
适应度函数设计
由于适应度函数通常是遗传算法中处理要求最高的部分,因此将代码改进集中在适应度函数上以获得最佳性能回报是有意义的。
在对适应度函数进行改进之前,最好先确保它能充分反映问题。遗传算法使用它的适应度函数来测量搜索空间的最佳区域,以集中它的搜索。这意味着设计不良的适应度函数会对遗传算法的搜索能力和整体性能产生巨大的负面影响。作为一个例子,想象一个遗传算法已经被建立来设计一个汽车面板,但是评估汽车面板的适应度函数完全是通过测量汽车的最高速度来完成的。如果面板满足一定的耐用性或人体工程学约束以及足够的空气动力学也很重要,那么这种过于简单的适合度函数可能无法提供足够的适合度值。
并行处理
现代计算机通常会配备几个独立的处理单元或“核心”。与标准单核系统不同,多核系统能够使用额外的内核同时处理多个计算。这意味着任何设计良好的应用都应该能够利用这一特性,允许其处理需求分布在可用的额外处理核心上。对于某些应用来说,这可能就像在一个内核上处理与 GUI 相关的计算,而在另一个内核上处理所有其他计算一样简单。
支持多核系统的优势是提高现代计算机性能的一种简单而有效的方法。正如我们之前讨论的,适应度函数经常会成为遗传算法的瓶颈。这使得它成为多核优化的完美候选。通过使用多个内核,可以同时计算众多个体的适应度,这在每个群体通常有数百个个体需要评估时会产生巨大的差异。
幸运的是,Java 8 提供了一些非常有用的库,使得在我们的遗传算法中支持并行处理变得更加容易。使用 IntStream,我们可以在我们的适应度函数中实现并行处理,而不用担心并行处理的精细细节(比如我们需要支持的内核数量);相反,它将根据可用内核的数量创建最佳数量的线程。
你可能想知道为什么在第五章中,遗传算法 calcFitness 方法在使用时间表对象之前克隆它。当线程化应用以进行并行处理时,需要注意确保一个线程中的对象不会影响另一个线程中的对象。在这种情况下,从一个线程对时间表对象所做的更改可能会对同时使用同一对象的其他线程产生意想不到的结果——首先克隆时间表允许我们为每个线程提供自己的对象。
我们可以通过修改 GeneticAlgorithm 的 evalPopulation 方法来使用 Java 的 IntStream,从而利用第五章的类调度器中的线程:
/**
* Evaluate population
*
* @param population
* @param timetable
*/
public void evalPopulation(Population population, Timetable timetable){
IntStream.range(0, population.size()).parallel()
.forEach(i -> this.calcFitness(population.getIndividual(i), timetable));
double populationFitness = 0;
// Loop over population evaluating individuals and suming population fitness
for (Individual individual : population.getIndividuals()) {
populationFitness += individual.getFitness();
}
population.setPopulationFitness(populationFitness);
}
现在,如果系统支持,calcFitness 函数可以跨多个内核运行。
因为本书中涉及的遗传算法使用了相当简单的适应度函数,并行处理可能不会提供太多的性能改进。测试并行处理能在多大程度上提高遗传算法性能的一个好方法可能是在适应度函数中添加对 Thread.sleep()的调用。这将模拟一个需要大量时间来完成执行的适应度函数。
适应值哈希
如前所述,适应度函数通常是遗传算法中计算量最大的部分。因此,即使是对适应度函数的微小改进也会对性能产生相当大的影响。值哈希是另一种方法,它可以通过将先前计算的适应值存储在哈希表中来减少计算适应值所花费的时间。在大型分布式系统中,您可以使用集中式缓存服务(如 Redis 或 memcached)来达到同样的目的。
在执行过程中,由于个体的随机突变和重组,以前发现的解决方案偶尔会被重新访问。随着遗传算法收敛并开始在搜索空间的越来越小的区域中寻找解决方案,这种偶尔重访解决方案变得更加常见。
每次重新访问解决方案时,都需要重新计算其适合度值,这将处理能力浪费在重复的计算上。幸运的是,这可以很容易地通过在计算后将适合度值存储在哈希表中来解决。当重新访问以前访问过的解决方案时,可以直接从哈希表中提取它的适应值,避免重新计算它。
要将适应性值散列添加到代码中,首先在 GeneticAlgorithm 类中创建适应性散列表,
// Create fitness hashtable
private Map<Individual, Double> fitnessHash = Collections.synchronizedMap(
new LinkedHashMap<Individual, Double>() {
@Override
protected boolean removeEldestEntry(Entry<Individual, Double> eldest) {
// Store a maximum of 1000 fitness values
return this.size() > 1000;
}
});
在这个例子中,在我们开始移除最老的值之前,散列表将存储最多 1000 个适合度值。这可以根据需要进行更改,以获得最佳的性能平衡。虽然更大的哈希表可以保存更多的适应值,但这是以内存使用为代价的。
现在,可以添加 get 和 put 方法来检索和存储适合度值。这可以通过更新 calcFitness 方法来完成,如下所示。请注意,我们已经从上一节中删除了 IntStream 代码,因此我们可以一次评估一个改进。
/**
* Calculate individual’s fitness value
*
* @param individual
* @param timetable
* @return fitness
*/
public double calcFitness(Individual individual, Timetable timetable){
Double storedFitness = this.fitnessHash.get(individual);
if (storedFitness != null) {
return storedFitness;
}
// Create new timetable object for thread
Timetable threadTimetable = new Timetable(timetable);
threadTimetable.createClasses(individual);
// Calculate fitness
int clashes = threadTimetable.calcClashes();
double fitness = 1 / (double) (clashes + 1);
individual.setFitness(fitness);
// Store fitness in hashtable
this.fitnessHash.put(individual, fitness);
return fitness;
}
最后,因为我们使用单个对象作为哈希表的键,所以我们需要覆盖单个类的“equals”和“hashCode”方法。这是因为我们需要根据个体的染色体生成散列,而不是根据默认情况下的对象本身。这一点很重要,因为具有相同染色体的两个独立个体应该被适应值哈希表识别为相同的。
/**
* Generates hash code based on individual’s
* chromosome
*
* @return Hash value
*/
@Override
public int hashCode() {
int hash = Arrays.hashCode(this.chromosome);
return hash;
}
/**
* Equates based on individual’s chromosome
*
* @return Equality boolean
*/
@Override
public boolean equals(Object obj) {
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
Individual individual = (Individual) obj;
return Arrays.equals(this.chromosome, individual.chromosome);
}
编码
影响遗传算法性能的另一个因素是所选择的编码。虽然从理论上讲,任何问题都可以用 0 和 1 的二进制编码来表示,但这很少是最有效的编码选择。
当遗传算法难以收敛时,通常可能是因为为问题选择了错误的编码,导致它在搜索新解时陷入困境。挑选一个好的编码没有什么难的,但是使用过于复杂的编码通常会产生不好的结果。例如,如果您想要一种可以对 0-10 之间的 10 个数字进行编码的编码,通常最好使用 10 个整数的编码,而不是二进制字符串。这样更容易应用变异和交叉函数,这些函数可以应用于单个整数,而不是表示整数值的位。这也意味着您不需要处理无效的染色体,例如代表值 15 的“1111 ”,这超出了我们要求的 0-10 范围。
变异和交叉方法
当考虑改进遗传算法性能的选项时,选择好的变异和交叉方法是另一个重要因素。要使用的最佳变异和交叉方法将主要取决于所选择的编码和问题本身的性质。一个好的变异或交叉方法应该能够产生有效的解,而且能够以预期的方式变异和交叉个体。
例如:如果我们正在优化一个接受 0-10 之间任何值的函数,一种可能的变异方法是高斯变异,它向基因添加一个随机值,稍微增加或减少其初始值。然而,另一种可能的突变方法是边界突变,其中选择上下边界之间的随机值来替换基因。这两种突变方法都能够产生有效的突变,但是根据问题的性质和实现的其他细节,一种方法可能会优于另一种方法。一个糟糕的突变方法可能只是根据原始值将值向下舍入到 0 或 10。在这种情况下,发生突变的数量取决于基因的价值,这可能导致表现不佳。初始值 1 将变为 0,这是一个相对较小的变化。然而,值 5 将变为大得多的 10。这种偏差会导致对更接近 0 和 10 的值的偏好,这通常会对遗传算法的搜索过程产生负面影响。
摘要
遗传算法可以以不同的方式进行修改,以实现显著的性能改进。在这一章中,我们看了许多不同的优化策略,以及如何将它们应用到遗传算法中。
自适应遗传算法是一种优化策略,可以提供优于标准遗传算法的性能改进。自适应遗传算法允许算法动态更新其参数,通常修改变异率或交叉率。这种参数的动态更新通常比不基于算法状态进行调整的静态定义的参数获得更好的结果。
我们在本章中考虑的另一个优化策略是多重试探法。该策略包括将遗传算法与另一种启发式算法(如模拟退火算法)相结合。通过将搜索字符与另一种启发式规则相结合,在这些特征有用的情况下,有可能实现性能改进。我们在本章中看到的模拟退火算法是基于冶金学中的退火过程。当在遗传算法中实现时,它允许最初在基因组中发生大的变化,然后逐渐减少变化量,从而允许算法专注于搜索空间中有希望的区域。
实现性能改进的最简单方法之一是优化适应度函数。适应度函数通常是计算开销最大的部分,这使得它非常适合优化。同样重要的是,健身功能是明确定义的,并提供个人实际健身的良好反映。如果适应度函数不能很好地反映个人的表现,它会减慢搜索过程,并将其引向搜索空间的不良区域。
一种优化适应度函数的简单方法是支持并行处理。通过一次处理多个适应度函数,可以大大减少遗传算法评估个体所花费的时间。
另一种可以用来减少处理适应度函数所需时间的策略是适应度值散列法。适应值散列使用散列表来存储多个最近使用的染色体的适应值。如果这些染色体再次出现在算法中,它可以召回适应值,而不是重新计算它。这可以防止对过去已经评估过的个人进行繁琐的重新处理。
最后,考虑改进遗传编码或使用不同的突变或交叉方法是否可以改进进化过程也是有效的。例如,使用不能很好地代表编码个体的编码,或者不能在基因组中产生所需多样性的突变方法,会导致算法停滞,并导致产生差的解决方案。