Python-遗传算法实用指南-全-
Python 遗传算法实用指南(全)
原文:
annas-archive.org/md5/37b689acecddb360565f499dd5ebf6d0译者:飞龙
序言
欢迎来到遗传算法及其在人工智能(AI)中的应用的精彩世界,本文通过 Python 编程的视角呈现。本书是一本全面的指南,将带你从遗传算法的基本原理到它们在 AI 中的强大应用,充分发挥 Python 的实用性和简洁性。
在计算机科学和问题解决领域,遗传算法因其独特的解决方案发现方式而脱颖而出。它模仿自然选择过程,以既迷人又高效的方式发展出问题的解答。我们的旅程从奠定这些算法的理论基础开始,详细介绍了核心组件和功能,如选择、交叉和变异。这为后续的高级概念和实际应用奠定了基础。
当我们从理论基础过渡到实际应用时,我们将开始使用 Python 解决实际问题。问题的范围从简单的谜题到复杂的优化挑战。接着,重点转向人工智能应用,其中遗传算法成为提升机器学习模型、解决复杂的强化学习任务,并深入探讨自然语言处理和新兴领域的可解释 AI 的重要工具。
本书认识到算法应用中性能优化的重要性,着手使用并发和云计算来提高遗传算法的效率、速度和可扩展性。
我们的旅程最终将进入图像重建和其他受生物启发的算法的迷人领域,揭示遗传算法的意想不到的创造潜力。
在这段旅程的结束时,你将深入理解遗传算法,拥有在各个领域应用这些算法的实践经验。本书不仅是一次学术探索,也是一部实用指南,能够帮助你有效地在实际场景中实现遗传算法。
无论你是学生、AI 领域的专业人士,还是单纯的好奇心驱使的探索者,这本书都将是一本宝贵的资源,帮助你解锁遗传算法在动态 AI 领域的潜力。
本书适合人群
本书适合数据科学家、软件开发者以及对遗传算法充满兴趣并希望迅速将其应用于现实智能应用的 AI 爱好者。它是为那些希望快速有效掌握遗传算法的人精心编写的。
本书的主要读者包括三个不同的群体:
-
数据科学家:本书是数据科学家的宝贵资源,帮助你将遗传算法融入工具箱。你将学习如何将这些算法应用于复杂的数据问题,提升你的预测模型和分析能力。
-
软件开发者:作为一名软件开发者,本书是将遗传算法融入软件解决方案的门户。无论你参与开发人工智能应用、优化工具,还是复杂系统仿真,理解如何实现和优化遗传算法将显著提升你软件的功能性和效率。本书提供了实际案例和逐步指导,帮助你将这些算法无缝地整合进你的项目中。
-
人工智能爱好者:如果你对人工智能充满热情并渴望拓展这一领域的知识,那么本书将对你特别有帮助。你将获得遗传算法的深入理解,这是人工智能的重要组成部分,并通过实践方法学习如何将其应用于各种人工智能驱动的应用中。所获得的技能和知识将成为你成为人工智能专家的重要一步。
本书内容概述
第一章,遗传算法概论,介绍了遗传算法的基本原理和理论,并与达尔文进化论进行对比。它将这些算法与传统算法进行了比较,讨论了它们的优势、局限性以及实际应用。最后,章节总结了遗传算法特别有效的应用场景。
第二章,理解遗传算法的关键组成部分,对遗传算法进行了全面的探讨,首先概述了遗传算法的基本流程。接着深入分析其核心组成部分,并逐步考察实际编码的遗传算法,涵盖了如精英主义、种群分化和共享等高级概念。最后,总结了使用遗传算法进行问题求解的技巧。
第三章,使用 DEAP 框架,介绍了 DEAP 框架(一个解决现实世界问题的多功能工具,利用遗传算法),带你了解其主要模块,并演示如何构建遗传算法的基本组件。这一过程通过编写解决OneMax问题的程序来展示,随后进行了不同遗传算法设置的实验,揭示了各种修改的影响。
第四章,组合优化,探讨了遗传算法在组合优化中的应用,并提供了基于 DEAP 框架的 Python 解决方案。涵盖的主要优化问题包括背包问题、旅行商问题和车辆路径问题。此外,章节还讨论了基因型到表型的映射,以及探索与利用之间的平衡。
第五章,约束满足,首先定义了约束满足的概念及其与搜索问题和组合优化的相关性。接着,章节通过 DEAP 框架展示了这些问题的实际案例及其解决方案。重点探讨的问题包括N-皇后问题、护士排班问题和图着色问题。此外,还讨论了硬约束与软约束之间的区别,以及它们在解决过程中的整合。
第六章,优化连续函数,探讨了遗传算法在优化连续搜索空间中的应用,使用基于实数的遗传算子和 DEAP 框架工具。该章介绍了基于 Python 的优化函数解决方案,包括Eggholder、Himmelblau和Simionescu函数,结合了像是分区和共享的技术,并解决了约束问题。
第七章,通过特征选择增强机器学习模型,讲解了如何利用遗传算法通过特征选择来增强监督式机器学习模型。章节首先介绍了机器学习,重点讨论回归和分类任务,并探讨了特征选择在提升模型性能方面的优势。接着,章节展示了在测试回归问题中使用遗传算法识别关键特征,并通过隔离最有效的特征,优化使用Zoo数据集的分类模型。
第八章,机器学习模型的超参数调优,探讨了通过基于遗传算法的超参数调优来提升监督式机器学习模型的性能。章节首先介绍了超参数调优和机器学习中的网格搜索概念,接着使用Wine数据集和自适应增强分类器作为案例研究。该章比较了传统的网格搜索与遗传算法驱动的网格搜索在超参数调优中的应用,最后尝试通过直接的遗传算法方法进一步优化结果。
第九章,深度学习网络的架构优化,重点介绍通过遗传算法驱动的网络架构优化来增强基于人工神经网络的模型。内容从神经网络和深度学习的介绍开始,接着通过使用Iris数据集和多层感知器分类器进行案例研究。本章展示了通过遗传算法驱动的解决方案进行网络架构优化,并将这种方法扩展到包括同时优化网络架构和模型超参数。
第十章,遗传算法在强化学习中的应用,展示了遗传算法在强化学习中的应用,使用Gymnasium工具包中的两个基准环境。内容从强化学习概述和 Gymnasium 工具包及其 Python 接口介绍开始,随后聚焦于解决MountainCar和CartPole环境中的挑战,开发针对这些特定挑战的遗传算法解决方案。
第十一章,自然语言处理,深入探讨了遗传算法与 NLP 的交叉领域。它介绍了 NLP 和词嵌入,并展示了它们在类似Semantle的谜题单词游戏中的应用,遗传算法在其中猜测谜题单词。本章还考察了 n-gram 和文档分类,运用遗传算法选择简洁高效的特征子集,从而加深了对分类器功能的理解。
第十二章,可解释的人工智能、因果关系与遗传算法中的反事实,研究了在可解释 AI 和因果关系中使用遗传算法生成“假设”场景,重点是反事实分析。它介绍了这些领域以及反事实的概念,随后通过使用遗传算法对German Credit Risk数据集进行实践应用,揭示了通过反事实分析获得的有价值见解。
第十三章,加速遗传算法——并发的力量,探讨了利用并发提升遗传算法性能的方式,重点是多处理。它讨论了应用并发的好处,并通过 Python 内建功能和外部库进行了演示。多种多处理方法在OneMax问题的 CPU 密集型变体上进行了测试,评估了所取得的性能改进。
第十四章,超越本地资源——在云中扩展遗传算法,扩展了通过客户端-服务器模型来提升遗传算法性能的内容,使用异步 I/O 和基于云的服务器计算,部署在AWS Lambda上。它讨论了拆分架构的优势,应用于OneMax问题,并引导你部署 Flask 服务器和 asyncio 客户端,最终展示如何将遗传算法在 AWS Lambda 上部署,展示云计算增强的遗传算法效率。
第十五章,基于遗传算法的图像重建,探讨了遗传算法在图像处理中的应用,重点讲解了如何使用半透明多边形重建图像。它首先概述了 Python 中的图像处理,并解释了如何通过多边形从零开始创建图像,以及如何计算图像差异。章节最后通过开发基于遗传算法的程序来重建一幅著名的画作,考察了进化过程及其结果。
第十六章,其他进化与生物启发式计算技术,介绍了与遗传算法相关的多种问题解决和优化技术。它涵盖了遗传编程、增强拓扑神经进化(NEAT)和粒子群优化,并通过问题解决的 Python 程序演示了每一种技术。章节最后概述了几种其他相关的计算范式。
为了最大化本书的价值
为了最大化本书的收益,拥有一定的 Python 基础知识是非常重要的。这个前提确保你能够无缝地理解和应用书中提供的概念和示例。无论你是想提升当前的职能角色,还是转向新的专业领域,本书都提供了在迷人的遗传算法世界中取得成功所需的实用知识和见解。
| 本书涵盖的软件/硬件 | 操作系统要求 |
|---|---|
| Python 3.11 | Windows、macOS 或 Linux |
| DEAP 1.4.1 | |
| 各种 Python 库 | |
| 亚马逊网络服务(第十四章) |
如果你使用的是本书的数字版本,我们建议你自己输入代码,或者通过书籍的 GitHub 仓库获取代码(相关链接将在下一部分提供)。这样做有助于你避免与代码复制粘贴相关的潜在错误 。
下载示例代码文件
你可以从 GitHub 下载本书的示例代码文件,链接地址为github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition。如果代码有更新,将会在 GitHub 仓库中进行更新。
我们还有其他来自丰富书籍和视频目录的代码包,您可以在 github.com/PacktPublishing/ 查看,快来看看吧!
使用的约定
本书中使用了多种文本约定。
文本中的代码: 表示文本中的代码词汇、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟网址、用户输入以及 Twitter 账户名。例如:“服务器使用 Flask 构建,而客户端利用 Python 的 asyncio 库进行异步操作。”
一段代码块格式如下所示:
def busy_wait(duration):
current_time = time.time()
while (time.time() < current_time + duration):
pass
当我们希望引起您对代码块中特定部分的注意时,相关行或项会以粗体显示:
@app.route("/")
def welcome():
return "<p>Welcome to our Fitness Evaluation Server!</p>"
所有命令行输入或输出格式如下所示:
pip install Flask
粗体:表示新术语、重要单词或您在屏幕上看到的词汇。例如,菜单或对话框中的词语通常会以 粗体 显示。例如:“点击功能名称将把我们带到 功能 概览 屏幕。”
提示或重要说明
以此方式显示。
联系我们
我们随时欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请通过 customercare@packtpub.com 向我们发送电子邮件,并在邮件主题中注明书名。
勘误表:尽管我们已尽一切努力确保内容的准确性,但错误难免发生。如果您在本书中发现任何错误,敬请告知。请访问 www.packtpub.com/support/errata 并填写表格。
盗版:如果您在互联网上发现任何形式的非法复制品,我们将非常感激您能提供其位置地址或网站名称。请通过 copyright@packt.com 与我们联系并提供材料的链接。
如果您有意成为作者:如果您对某一主题有专业知识,并且有兴趣撰写或参与书籍创作,请访问 authors.packtpub.com。
分享您的想法
阅读完 Hands-On Genetic Algorithms with Python, Second Edition 后,我们非常欢迎您的反馈!请点击此处直接进入亚马逊评价页面并分享您的反馈。
您的评价对我们以及技术社区都非常重要,将帮助我们确保提供高质量的内容。
免费下载本书的 PDF 版本
感谢您购买本书!
您喜欢随时随地阅读,但又不能随身携带纸质书籍吗?
您的电子书购买是否与您选择的设备不兼容?
不用担心,现在每本 Packt 书籍都会附赠一份无 DRM 的 PDF 版本,免费提供。
随时随地,在任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
福利不仅仅是这些,您还可以每天获得独家折扣、新闻通讯和精彩的免费内容,直接发送到您的邮箱。
按照以下简单步骤获取福利:
- 扫描二维码或访问下面的链接

packt.link/free-ebook/978-1-80512-379-8
-
提交您的购买凭证
-
就这样!我们会直接将您的免费 PDF 和其他福利发送到您的电子邮件
第一部分:遗传算法基础
在本节中,您将被介绍遗传算法的关键概念,从达尔文进化类比开始,基本原理和理论基础。然后我们将深入探讨这些算法的组成部分和实现细节,探索它们的流程以及选择、交叉和变异的各种方法。本节还专注于实数编码的遗传算法和诸如精英主义、细分和共享等高级概念,为后续章节中的问题解决做好铺垫。
这一部分包含以下章节:
-
第一章**, 遗传算法简介
-
第二章**, 理解遗传算法的关键组成部分
第一章:遗传算法简介
从查尔斯·达尔文的自然进化理论中汲取灵感,解决问题的最具魅力的技术之一就是被恰当地命名为进化计算的算法家族。在这个家族中,最突出且广泛应用的分支被称为遗传算法。本章将是你掌握这一既极其强大又极其简单的技术的起点。
在本章中,我们将介绍遗传算法及其与达尔文进化的类比,然后深入探讨其基本操作原理和基础理论。接着,我们将讲解遗传算法与传统算法的区别,并讨论遗传算法的优缺点及其应用。最后,我们将回顾使用遗传算法可能带来益处的案例。
在本章节的介绍中,我们将涵盖以下主题:
-
什么是遗传算法?
-
遗传算法背后的理论
-
遗传算法与传统算法的区别
-
遗传算法的优点和局限性
-
何时使用遗传算法
什么是遗传算法?
遗传算法是一类受自然进化原理启发的搜索算法。通过模仿自然选择和繁殖的过程,遗传算法能够为各种涉及搜索、优化和学习的问题提供高质量的解决方案。同时,遗传算法与自然进化的类比使其能够克服传统搜索和优化算法所面临的一些障碍,特别是对于具有大量参数和复杂数学表示的问题。
在本节的其余部分,我们将回顾遗传算法的基本思想,以及它们与自然界中发生的进化过程的类比。
达尔文进化
遗传算法实现了自然界中达尔文进化的简化版本。达尔文进化理论的原理可以通过以下原则来概括:
-
变异原理:属于同一群体的个体的特征(属性)可能会有所不同。因此,个体之间在某种程度上有所差异,例如在行为或外观上。
-
遗传原理:某些特征会持续从个体传递给其后代。因此,后代更像其父母,而不像无关的个体。
-
选择原理:群体通常会在其所处环境中争夺资源。那些具有更好适应环境特征的个体,将更有可能存活,并且会为下一代贡献更多的后代。
换句话说,进化保持了一个个体样本群体,这些个体彼此之间存在差异。那些更适应环境的个体有更大的生存、繁殖并将其特征传递给下一代的机会。通过这种方式,随着代际的更替,物种会越来越适应其环境以及面临的挑战。
进化的一个重要推动因素是交叉或重组——通过将父母特征混合来创造后代。交叉有助于维持群体的多样性,并随着时间的推移将更好的特征结合在一起。此外,突变——特征上的随机变化——通过引入变化,偶尔也能在进化中起到推动作用,从而实现跃进。
基因算法类比
基因算法旨在为给定的问题寻找最优解,而达尔文进化则保持一个个体样本群体。基因算法保持一个候选解群体,称为个体,用于给定的问题。这些候选解会经过反复评估,并用于创建新一代的解。那些更擅长解决这个问题的候选解有更大的机会被选中,并将它们的特质传递给下一代候选解。通过这种方式,随着代际更替,候选解在解决当前问题时会变得越来越优秀。
在接下来的章节中,我们将描述基因算法的各个组成部分,这些组成部分使得达尔文进化类比成为可能。
基因型
在自然界中,繁殖、交配和突变是通过基因型来实现的——基因型是由一组基因组成的染色体集合。如果两个个体交配并产生后代,每个后代的染色体都会携带来自两个父母的基因混合。模仿这一概念,在基因算法中,每个个体由一个染色体表示,染色体代表一组基因。例如,染色体可以表示为一个二进制字符串,其中每个比特代表一个基因:

图 1.1:简单的二进制编码染色体
图 1.1 展示了一个这样的二进制编码染色体的例子,表示一个特定个体。
群体
在任何时刻,基因算法都会保持一个个体群体——即一组当前问题的候选解。由于每个个体由某种染色体表示,因此这些个体的群体可以看作是这些染色体的集合:

图 1.2:由二进制编码染色体表示的个体群体
该群体持续代表当前的世代,并在当前世代被新一代替换时发生进化。
适应度函数
在算法的每次迭代中,使用适应度函数(也称为目标函数)来评估个体。这是我们要优化的函数或我们尝试解决的问题。
达到更高适应度得分的个体代表更好的解决方案,更有可能被选择进行繁殖,并在下一代中得到体现。随着时间的推移,解决方案的质量提高,适应度值增加,一旦找到满意的适应度值,过程就可以停止。
选择
在计算每个个体的适应度后,使用选择过程来确定种群中的哪些个体将进行繁殖,并产生下一代的后代。
这个选择过程是基于个体的适应度得分。得分较高的个体更有可能被选择,并将其遗传物质传递给下一代。
适应度较低的个体仍然可以被选择,但概率较低。通过这种方式,它们的遗传物质不会完全被排除,从而保持遗传多样性。
交叉
为了创造一对新个体,通常从当前一代中选择两个父母,并交换它们染色体的一部分(交叉),以产生两条新的染色体,代表后代。这个操作称为交叉或重组:

图 1.3:两个二进制编码染色体之间的交叉操作
来源:commons.wikimedia.org/wiki/File:Computational.science.Genetic.algorithm.Crossover.One.Point.svg
图片来源:Yearofthedragon
图 1**.3 展示了一个简单的交叉操作,通过两个父母创造两个后代。
变异
变异算子的目的是刷新种群,将新的模式引入染色体中,并定期和随机地鼓励在解决方案空间的未知区域进行搜索。
变异可能表现为基因的随机变化。变异是通过对染色体值中的一个或多个进行随机更改来实现的;例如,在二进制串中翻转一个比特:

图 1.4:应用于二进制编码染色体的变异算子
图 1**.4 显示了变异操作的示例。
现在,让我们来看看遗传算法背后的理论。
遗传算法背后的理论
遗传算法的基础假设是,当前问题的最优解由小的构建块组成,随着我们将更多的构建块组合在一起,我们会越来越接近这个最优解。
种群中的个体,通过其优越的得分,识别出包含部分期望基因片段的个体。反复进行选择和交叉操作,使得优良个体将这些基因片段传递给下一代,同时可能与其他成功的基因片段组合。这种过程形成遗传压力,引导种群朝向拥有更多期望基因片段的方向,最终形成最优解。
因此,每一代都会比上一代更好,包含更多接近最优解的个体。
例如,考虑一个四位二进制字符串的种群,其中我们的目标是找到和数字之和最高的字符串。这被称为OneMax问题,并将在本书后面详细讨论。在这种情况下,出现在四个可能数字位置的 1 是一个很好的基因片段。随着算法的推进,它会识别出含有这些基因片段的解并将它们组合在一起。每一代会有更多个体在不同位置包含 1 值,最终产生包含所有期望基因片段的字符串 1111。该过程如下图所示:

图 1.5:交叉操作展示了如何将最优解的基因片段结合在一起
图 1.5 展示了两个对于该问题来说是优秀解的个体(每个个体有三个 1 值)如何通过交叉操作,结合父母双方的期望基因片段,从而产生最佳解(四个 1 位,也就是右边的后代)。
该模式定理
构建块假设的更正式表达是霍兰德的模式定理,也叫做遗传算法的基本定理。
该定理涉及到模式(模式的复数形式),它们是可以在染色体中找到的模式(或模板)。每个模式表示一组染色体,它们在某些方面具有相似性。
例如,如果四位二进制字符串表示染色体集合,那么模式101表示所有那些在最左端位置有 1、在最右端两个位置有 01、以及在从左数第二的位置可能有 1 或 0 的染色体,因为表示通配符值。
对于每个模式,我们可以分配两个测量值:
-
阶数:固定数字的数量(非通配符)
-
定义长度:两个最远固定数字之间的距离
以下表格提供了几个四位二进制模式及其测量值的示例:
| 模式 | 阶数 | 定义长度 |
|---|---|---|
| 1101 | 4 | 3 |
| 1*01 | 3 | 3 |
| *101 | 3 | 2 |
| 11 | 2 | 2 |
| **01 | 2 | 1 |
| 1*** | 1 | 0 |
| **** | 0 | 0 |
表 1.1:四位二进制模式及其对应的度量示例
种群中的每个染色体都对应着多个模式,就像给定的字符串与正则表达式匹配一样。例如,染色体 1101 对应于该表中所有出现的模式,因为它与这些模式所代表的每一个模式都匹配。如果该染色体的得分较高,它更有可能在选择操作中存活下来,并且所有它代表的模式也会一起存活。当该染色体与另一个染色体交叉,或发生变异时,一些模式会存活下来,而其他模式则会消失。低阶和短定义长度的模式更有可能存活。
因此,模式定理指出,低阶、短定义长度和优于平均适应度的模式的频率会在后代中呈指数增长。换句话说,代表使得解更好的小而简单的构建块将在遗传算法的进程中越来越频繁地出现在种群中。我们将在下一节讨论遗传算法和传统算法之间的区别。
与传统算法的区别
遗传算法与传统的搜索和优化算法(如基于梯度的算法)之间有几个重要的区别。
关键的区分因素如下:
-
维持一个解的种群
-
使用解的遗传表示
-
利用适应度函数的结果
-
展现出概率行为
我们将在以下章节中更详细地描述这些因素。
基于种群
遗传搜索是在候选解的种群(个体)上进行的,而不是单一候选解。在搜索的任何时刻,算法会保留一组个体,形成当前的世代。遗传算法的每次迭代都会创造出下一个世代的个体。
相比之下,大多数其他搜索算法保持一个单一解,并通过迭代修改该解来寻找最佳解。例如,梯度下降算法通过在最陡下降方向上迭代地移动当前解来优化解,该方向由给定函数的梯度的负值定义。
遗传表示
遗传算法不是直接作用于候选解,而是作用于它们的表示(或编码),通常称为染色体。一个简单染色体的例子是一个固定长度的二进制字符串。
这些染色体使我们能够进行交叉和变异的遗传操作。交叉通过交换两个父本的染色体部分来实现,而变异则通过修改染色体的部分来实现。
使用遗传表示法的一个副作用是将搜索与原始问题域解耦。遗传算法并不关心染色体代表的内容,也不会试图解释它们。
适应度函数
适应度函数表示我们希望解决的问题。遗传算法的目标是找到在该函数计算后获得最高分的个体。
与许多传统搜索算法不同,遗传算法只考虑通过适应度函数得到的值,而不依赖于导数或任何其他信息。这使得它们适合处理那些难以或无法数学上求导的函数。
概率行为
虽然许多传统算法是确定性的,但遗传算法通过的规则是概率性的,即从一代到下一代的进化过程是基于概率的。
例如,在选择用于创建下一代的个体时,选择某个个体的概率会随着该个体适应度的提高而增加,但在选择过程中仍然存在随机元素。适应度较低的个体也可能被选择,但概率较低。
突变也具有概率驱动特性,通常以较低的概率发生,并在染色体的一个或多个随机位置进行改变。
交叉算子也可以包含一个概率元素。在某些遗传算法的变种中,交叉只会在一定的概率下发生。如果没有发生交叉,两个父代将直接复制到下一代,保持不变。
尽管这一过程具有概率性,但基于遗传算法的搜索并非完全随机;相反,它利用随机元素将搜索引导至搜索空间中更有可能改进结果的区域。现在,让我们看看遗传算法的优势。
遗传算法的优势
我们在前面讨论的遗传算法独特特性提供了相对于传统搜索算法的几个优势。
遗传算法的主要优势如下:
-
全局优化能力
-
能处理具有复杂数学表示的问题
-
能处理缺乏数学表示的问题
-
对噪声的韧性
-
支持并行处理和分布式处理
-
适合持续学习
我们将在接下来的章节中讨论这些内容。
全局优化
在许多情况下,优化问题存在局部最大值和最小值点;这些点代表比周围更好的解,但并非整体最优。
以下图示说明了全局最大值和最小值与局部最大值和最小值的区别:

图 1.6:函数的全局和局部最大值与最小值。
来源:commons.wikimedia.org/wiki/File:Computational.science.Genetic.algorithm.Crossover.One.Point.svg
图片由 KSmrq 提供
大多数传统的搜索和优化算法,尤其是基于梯度的算法,容易陷入局部最大值,而不是找到全局最大值。这是因为在局部最大值附近,任何小的变化都会导致评分下降。
另一方面,遗传算法对这种现象不太敏感,更容易找到全局最大值。这是因为遗传算法使用的是一组候选解,而不是单一解,并且交叉和变异操作通常会导致候选解与之前的解相距较远。只要我们能保持种群的多样性,避免过早收敛,正如我们将在下一节中提到的那样,这种情况就成立。
处理复杂问题
由于遗传算法只需要每个个体的适应度函数结果,并且不关心适应度函数的其他方面(例如导数),它们可以用于具有复杂数学表示的问题,或者用于那些难以或无法求导的函数。
遗传算法在其他复杂案例中表现出色,包括具有大量参数的问题和混合参数类型的问题——例如,连续参数和离散参数的组合。
处理缺乏数学表示的问题
遗传算法可以用于完全缺乏数学表示的问题。一个特别值得关注的情况是,当适应度评分基于人类意见时。例如,假设我们希望找到一个最具吸引力的色彩调色板来应用于网站。我们可以尝试不同的颜色组合,并要求用户评价网站的吸引力。我们可以应用遗传算法来搜索得分最高的组合,同时使用这种基于意见的评分作为适应度函数的输出。即使适应度函数缺乏数学表示,并且无法直接从给定的颜色组合中计算得分,遗传算法仍然可以正常工作。
正如我们将在下一章中看到的那样,遗传算法甚至可以处理每个个体的得分无法获得的情况,只要我们有办法比较两个个体并确定哪一个更好。一个例子是,基于机器学习算法的模拟赛车驾驶。基于遗传算法的搜索可以通过让不同版本的算法相互竞争,来优化和调优该机器学习算法,以确定哪个版本更好。
对噪声的鲁棒性
一些问题表现出噪声行为。这意味着,即使是相似的输入参数值,每次测量时输出值可能会有所不同。例如,当使用的数据来自传感器输出,或者在分数基于人类意见的情况下(如前一节所讨论的)就会发生这种情况。
虽然这种行为可能会扰乱许多传统的搜索算法,但由于遗传算法在重新组装和重新评估个体时的重复操作,它通常对这种情况具有较强的抵抗力。
并行性
遗传算法非常适合并行化和分布式处理。适应度是针对每个个体独立计算的,这意味着种群中的所有个体都可以同时进行评估。
此外,选择、交叉和变异操作可以在种群中的个体和个体对上并行执行。
这使得遗传算法成为分布式和云端实现的天然候选者。
持续学习
在自然界中,进化永不停息。随着环境条件的变化,种群会相应适应这些变化。同样,遗传算法可以在不断变化的环境中持续运行,并且在任何时刻,都可以获取并使用当前最优解。
为了使其有效,环境的变化相较于遗传算法搜索的代际周转速率应该是缓慢的。现在我们已经讨论了遗传算法的优势,接下来让我们看看它的局限性。
遗传算法的局限性
为了最大限度地发挥遗传算法的作用,我们需要了解它们的局限性和潜在的陷阱。
遗传算法的局限性如下:
-
对特殊定义的需求
-
需要超参数调优
-
计算密集型操作
-
提前收敛的风险
-
无法保证得到最优解
我们将在接下来的章节中逐一讨论这些问题。
特殊定义
在将遗传算法应用于特定问题时,我们需要为其创建一个合适的表示——定义适应度函数和染色体结构,并为该问题设计合适的选择、交叉和变异操作符。这往往是一项既具有挑战性又费时的任务。
幸运的是,遗传算法已经应用于无数种不同类型的问题,许多定义已经标准化。本书涵盖了许多现实生活中的问题及其如何通过遗传算法解决。遇到新问题时,可以参考这些内容作为指导。
超参数调优
遗传算法的行为由一组超参数控制,例如种群大小和变异率。在将遗传算法应用于当前问题时,并没有确切的规则来做出这些选择。
然而,这对于几乎所有的搜索和优化算法都是如此。在本书中的示例和你自己的实验之后,你将能够为这些值做出明智的选择。
计算密集型
在(潜在较大)种群上操作以及遗传算法的重复性质可能会导致计算密集型和耗时,在获得良好结果之前需要较长时间。
这些问题可以通过良好的超参数选择、实现并行处理,且在某些情况下通过缓存中间结果来缓解。
早熟收敛
如果某个个体的适应度远高于其余种群,它可能会被复制足够多次,导致其占据整个种群。这可能会导致遗传算法过早地陷入局部最优解,而不是找到全局最优解。
为了防止这种情况发生,保持种群的多样性非常重要。维持多样性的方法将在下一章讨论。
没有保证的解决方案
使用遗传算法并不能保证找到当前问题的全局最优解。
然而,这几乎是所有搜索和优化算法的通病,除非它是某类特定问题的解析解。
通常,遗传算法在适当使用时,能够在合理的时间内提供良好的解决方案。现在,让我们来看一下遗传算法的一些应用案例。
遗传算法的应用案例
基于我们在前面章节中涵盖的内容,遗传算法最适用于以下类型的问题:
-
具有复杂数学表示的问题:由于遗传算法只需要适应度函数的结果,因此它们可以用于具有难以或不可能微分的目标函数的问题(例如规划和调度问题)、具有大量参数的问题(例如图像重建)以及具有混合参数类型的问题(例如超参数优化)。
-
没有数学表示的问题:遗传算法不需要问题的数学表示,只要能够获得评分值或提供比较两个解决方案的方法即可。这在解决强化学习任务或优化深度学习模型架构时非常有用。
-
涉及噪声环境的问题:遗传算法对于数据可能不一致的情况具有较强的适应性,例如来自传感器输出或基于人工评分的信息;例如,根据客户反馈和使用模式选择网站的最佳配色方案。
-
涉及到随时间变化的环境问题:遗传算法能够通过不断产生新一代来应对环境中的缓慢变化,从而适应这些变化。回到前面提到的网站配色方案示例,客户的偏好颜色可能会随着时尚趋势的变化而发生变化。另一方面,当一个问题有已知且专门的解决方法时,使用现有的传统或分析方法通常是更高效的选择。
至此,本章结束。
总结
本章首先介绍了遗传算法,它与达尔文进化论的类比以及其基本操作原理,包括使用种群、基因型、适应度函数,以及选择、交叉和变异的遗传操作符。
接着,我们通过讲解构建模块假设和模式定理,阐述了遗传算法背后的理论,并通过将优越的小模块组合在一起,展示了遗传算法如何工作,以创造最佳的解决方案。
接下来,我们讲解了遗传算法与传统算法的不同之处,比如保持解决方案种群和使用这些解决方案的遗传表示。
我们接着介绍了遗传算法的优点,包括其全球优化能力、处理复杂或没有数学表示的问题的能力,以及对噪声的韧性,随后是其缺点,包括对特殊定义和超参数调整的需求,以及过早收敛的风险。
我们总结时回顾了使用遗传算法可能带来好处的情形,例如在数学复杂的问题和噪声或不断变化的环境中的优化任务。
在下一章,我们将深入探讨遗传算法的关键组件和实现细节,为接下来的章节做准备,在那些章节中我们将使用遗传算法来编码解决各种类型的问题。
深入阅读
欲了解更多关于本章内容的信息,请参考 Amita Kapoor 于 2019 年 1 月出版的《Hands-On Artificial Intelligence for IoT》一书中的遗传算法入门,该书可通过subscription.packtpub.com/book/big_data_and_business_intelligence/9781788836067获取。
第二章:理解遗传算法的关键组成部分
在本章中,我们将深入探讨遗传算法的关键组成部分及其实现细节,为接下来的章节做准备,在这些章节中我们将使用遗传算法为各种类型的问题创建解决方案。
首先,我们将概述遗传算法的基本流程,然后将其拆解为不同的组成部分,并展示选择方法、交叉方法和变异方法的各种实现。接下来,我们将探讨实数编码的遗传算法,这些算法便于在连续参数空间中进行搜索。接下来是对精英主义、物种分化和共享等遗传算法中的有趣话题的概述。最后,我们将学习如何利用遗传算法解决问题。
到本章结束时,你将能够做到以下几点:
-
熟悉遗传算法的关键组成部分
-
理解遗传算法流程的各个阶段
-
理解遗传操作符,并熟悉它们的几种变体
-
了解停止条件的各种选择
-
理解当遗传算法应用于实数时需要做出哪些修改
-
理解精英主义的机制
-
理解物种分化和共享的概念与实现
-
了解在开始处理新问题时需要做出的选择
遗传算法的基本流程
基本遗传算法流程的主要阶段如下图所示:

图 2.1:遗传算法的基本流程
这些阶段将在接下来的部分中详细描述。
创建初始种群
初始种群是一组有效的候选解(个体),它们是随机选择的。由于遗传算法使用染色体来表示每个个体,初始种群就是一组染色体。这些染色体应该符合我们为当前问题选择的染色体格式——例如,某个长度的二进制字符串。
计算适应度
每个个体的适应度函数值都会被计算出来。初始种群会进行一次这样的计算,之后每一代新的个体会在应用选择、交叉和变异的遗传操作符后再进行计算。由于每个个体的适应度与其他个体无关,这一计算可以并行执行。
由于适应度计算后进行的选择阶段通常认为具有更高适应度的个体是更好的解,因此遗传算法天然倾向于寻找适应度函数的最大值。如果我们遇到一个要求最小值的问题,那么适应度计算应该将原始值反向处理——例如,将其乘以(-1)。
应用选择、交叉和变异
应用选择、交叉和变异的遗传算子到种群中,能够生成一个新的世代,该世代基于比当前个体更优秀的个体。
-
选择算子负责以一种有利于优秀个体的方式,从当前种群中选择个体。选择算子的例子在选择 方法部分中给出。
-
交叉(或重组)算子通过将选定个体的染色体部分交换,创造出新的后代。通常是每次选择两个个体,并交换它们的部分染色体,生成两个新的染色体,代表后代。选择算子的例子在交叉 方法部分中给出。
-
变异算子可以随机地对每个新生成个体的一个或多个染色体值(基因)进行变化。变异通常以非常低的概率发生。变异算子的例子在变异 方法部分中给出。
检查停止条件
在判断过程是否可以停止时,可能会有多个条件需要检查。以下是最常用的两种停止条件:
-
达到最大代数。这也有助于限制算法消耗的运行时间和计算资源。
-
在最近几代中没有明显的改进。这可以通过存储每代获得的最佳适应度值,并将当前最佳值与预定义代数之前的最佳值进行比较来实现。如果它们之间的差异小于某个阈值,则算法可以停止。
这里还有一些可能的停止条件:
-
当前最佳个体的表现已经达到了或超过了特定应用场景的要求
-
自过程开始以来已经经过了预定的时间
-
已消耗了某些成本或预算,例如 CPU 时间和/或内存
-
最优解已经占据了超过预设阈值的一部分种群
总结来说,遗传算法的流程从一群随机生成的候选解(个体)开始,这些个体会根据适应度函数进行评估。流程的核心是一个循环,在循环中,遗传算子的选择、交叉和变异会依次应用,然后再对个体进行重新评估。该循环会持续进行,直到遇到停止条件,此时会选择现有种群中最优的个体作为我们的解。现在,让我们来看一下选择方法。
选择方法
在遗传算法流程的每个周期开始时使用选择,从当前种群中选择个体作为下一代个体的父母。选择是基于概率的,个体被选中的概率与其适应度值相关联,这样有助于高适应度值的个体获得优势。
下面的章节描述了一些常用的选择方法及其特点。
轮盘赌选择
在轮盘赌选择方法中,也称为适应度比例选择(FPS),选择个体的概率与其适应度值成正比。这类似于在赌场使用轮盘赌的方式,为每个个体分配一个与其适应度值成比例的轮盘部分。当轮盘转动时,每个个体被选中的概率与其所占轮盘部分的大小成正比。
例如,假设我们有一个六个个体的种群,其适应度值如下表所示。基于这些适应度值计算每个个体所占轮盘的相对部分:
| 个体 | 适应度 | 相对部分 |
|---|---|---|
| A | 8 | 7% |
| B | 12 | 11% |
| C | 27 | 24% |
| D | 4 | 3% |
| E | 45 | 40% |
| F | 17 | 15% |
表 2.1:适应度值表
匹配的轮盘图如下所示:

图 2.2:轮盘赌选择示例
每次转动轮盘时,选择点用于从整个种群中选择一个个体。然后再次转动轮盘以选择下一个个体,直到选出足够的个体填充下一代。因此,同一个个体可以被多次选择。
随机全局采样
随机全局采样(SUS)是前述轮盘赌选择的略微修改版本。仍然使用相同的轮盘赌,具有相同的比例,但不同的是,我们只转动一次轮盘并使用多个等间隔的选择点,以一次性选择所有需要的个体,如下图所示:

图 2.3:SUS 示例
该选择方法防止具有特别高适应度值的个体在过多次选择中主导下一代。因此,它为较弱的个体提供了被选中的机会,减少了原始轮盘赌选择方法的某种不公平性。
基于排名的选择
排名选择方法类似于轮盘赌选择,但它并不是直接使用适应度值来计算每个个体的选择概率,而是仅仅使用适应度值对个体进行排序。一旦排序完成,每个个体就会被分配一个表示其位置的排名,轮盘赌的概率则基于这些排名计算。
例如,假设我们使用之前的六个个体的种群,并且他们的适应度值相同。然后,我们会为每个个体添加一个排名。因为在我们的例子中,种群大小为六,所以排名最高的个体获得排名 6,第二个获得排名 5,依此类推。现在,每个个体在轮盘赌中的相对部分是基于这些排名值计算的,而不是使用适应度值:
| 个体 | 适应度 | 排名 | 相对部分 |
|---|---|---|---|
| A | 8 | 2 | 9% |
| B | 12 | 3 | 14% |
| C | 27 | 5 | 24% |
| D | 4 | 1 | 5% |
| E | 45 | 6 | 29% |
| F | 17 | 4 | 19% |
表 2.2:基于相对部分的适应度值表
匹配的轮盘赌如以下图所示:

图 2.4:基于排名的选择示例
当少数个体的适应度值远大于其他个体时,排名选择是有用的。使用排名代替原始适应度可以防止这些少数个体在下一代中占据主导地位,因为排名消除了大差异。
另一个有用的情况是当所有个体的适应度值相似时。在这种情况下,排名选择将把它们分开,即使适应度差异很小,也能给更优秀的个体更清晰的优势。
适应度缩放
排名选择通过将每个适应度值替换为个体的排名,而适应度缩放则对原始适应度值应用缩放变换,并用变换后的结果替换原始值。该变换将原始适应度值映射到一个期望的范围,如下所示:
缩放后的适应度 = a × (原始适应度) + b
在这里,a 和 b 是我们可以选择的常数,用来实现所期望的缩放适应度范围。
例如,如果我们使用之前示例中的相同值,原始适应度值的范围是从 4(最低适应度值,个体 D)到 45(最高适应度值,个体 E)。假设我们想将这些值映射到一个新的范围,介于 50 和 100 之间。我们可以使用以下方程计算a和b常数,这代表这两个个体:
-
50 = a × 4 + b(最低适应度值)
-
100 = a × 45 + b(最高适应度值)
解这个简单的线性方程组将得到以下缩放参数值:
a = 1.22, b = 45.12
这意味着缩放后的适应度值可以通过以下方式计算:
缩放后的适应度 = 1.22 × (原始适应度) + 45.12
在表格中新增一个包含缩放适应度值的列后,我们可以看到适应度值的范围确实在 50 到 100 之间,如预期的那样:
| 个体 | 适应度 | 缩放后的适应度 | 相对比例 |
|---|---|---|---|
| A | 8 | 55 | 13% |
| B | 12 | 60 | 15% |
| C | 27 | 78 | 19% |
| D | 4 | 50 | 12% |
| E | 45 | 100 | 25% |
| F | 17 | 66 | 16% |
匹配的轮盘在以下图示中展示:

图 2.5:适应度缩放后轮盘选择示例
如图 2.5所示,将适应度值缩放到新的范围后,相比原始的分割,轮盘的分割更为温和。最佳个体(缩放适应度值为 100)现在仅有原最差个体(缩放适应度值为 50)的两倍机会被选择,而不是在使用原始适应度值时有超过 11 倍的选择概率。
锦标赛选择
在每轮锦标赛选择方法中,会从种群中随机挑选两个或更多个体,拥有最高适应度分数的个体获胜并被选中。
例如,假设我们有相同的六个个体,并使用与之前示例中相同的适应度值。以下图示展示了随机选择其中三个个体(A、B 和 F),然后宣布 F 为赢家,因为在这三者中,它的适应度值(17)是最大的:

图 2.6:锦标赛规模为三的锦标赛选择示例
每轮锦标赛选择中参与的个体数量(在我们的示例中为三人)通常被称为锦标赛规模。锦标赛规模越大,最佳个体参与锦标赛的机会越高,低分个体赢得锦标赛并被选中的机会越小。
这种选择方法的一个有趣方面是,只要我们能够比较任意两个个体并确定哪个个体更优秀,就不需要实际的适应度函数值。接下来,我们将讨论交叉方法。
交叉方法
交叉算子,也称为重组,类似于生物学中性繁殖过程中发生的交叉,用于将两个个体的遗传信息结合起来,作为父母产生(通常是两个)后代。
交叉算子通常以某个(较高的)概率值应用。每当交叉不被应用时,两个父代个体将直接克隆到下一代中。
以下部分描述了一些常用的交叉方法及其典型应用场景。然而,在某些情况下,你可能会选择使用特定问题的交叉方法,这样会更适合特定的案例。
单点交叉
在单点交叉方法中,随机选择父母染色体上的某个位置,称为交叉点或切割点。交叉点右侧的基因在两个父母染色体之间交换。因此,我们得到两个后代,每个后代都携带来自两个父母的一些遗传信息。
以下图示演示了在一对二进制染色体上进行单点交叉操作,交叉点位于第五和第六基因之间:

图 2.7:单点交叉示例
来源:commons.wikimedia.org/wiki/File:Computational.science.Genetic.algorithm.Crossover.One.Point.svg。
图片由 Yearofthedragon 提供。
在下一节中,我们将介绍这种方法的扩展,即两点交叉和 k 点交叉。
两点交叉和 k 点交叉
在两点交叉方法中,随机选择父母染色体上的两个交叉点。在这两个交叉点之间的基因将在两个父母染色体之间交换。
以下图示演示了在一对二进制染色体上进行两点交叉操作,第一次交叉点位于第三和第四基因之间,第二次交叉点位于第七和第八基因之间:

图 2.8:两点交叉示例
来源:commons.wikimedia.org/wiki/File:Computational.science.Genetic.algorithm.Crossover.Two.Point.svg。
图片由 Yearofthedragon 提供。
两点交叉方法可以通过执行两个单点交叉来实现,每次使用不同的交叉点。此方法的推广是 k 点交叉,其中k表示正整数,使用k个交叉点。
均匀交叉
在均匀交叉方法中,每个基因通过随机选择一个父母的基因来独立决定。当随机分布为 50%时,每个父母对后代的影响机会相同,具体如以下图所示:

图 2.9:均匀交叉示例
请注意,在这个示例中,第二个后代是通过补充第一个后代的选择而生成的。然而,两个后代也可以彼此独立地创建。
重要说明
在这个示例中,我们使用了基于整数的染色体,但如果使用二进制染色体也会类似。
由于这种方法不会交换整个染色体的片段,因此它在产生后代时具有更大的多样性潜力。
有序列表的交叉
在前面的例子中,我们看到对两个基于整数的染色体进行交叉操作的结果。虽然每个父代的基因值在 0 到 9 之间各自出现一次,但每个后代的基因值中某些出现了多次(例如,顶部的后代中有 2,另一个后代中有 1),而其他基因值则缺失(例如,顶部的后代缺少 4,另一个后代缺少 5)。
然而,在某些任务中,基于整数的染色体可能表示有序列表的索引。例如,假设我们有几个城市,我们知道每两个城市之间的距离,需要找出穿越所有城市的最短路径。这就是旅行商问题,在接下来的章节中将详细讲解。
举例来说,如果我们有四个城市,一个方便的方法是用四个整数表示这个问题的可能解,显示访问这些城市的顺序——例如,(1,2,3,4)或(3,4,2,1)。如果一个染色体中有两个相同的值或缺失其中一个值,比如(1,2,2,4),则不能表示一个有效的解。
对于这种情况,设计了替代的交叉方法,以确保产生的后代仍然有效。其中一种方法,有序交叉(OX1),将在接下来的章节中进行介绍。
OX1
OX1 方法力图尽可能保持父代基因的相对顺序。我们将通过使用长度为六的染色体来展示这一方法。
在这个例子中,我们使用了基于整数的染色体,但对于二进制染色体,操作也类似。
第一步是进行一个带有随机切点的两点交叉,如下图所示(父代基因在左侧表示):

图 2.10:OX1 示例 – 第 1 步
接下来,我们将开始按照原始顺序遍历每个父代基因,从第二个切点后开始填充每个后代的基因。对于第一个父代,我们发现了一个6,但这个基因已经存在于后代中,因此继续(循环回绕)到1;这个基因也已经存在。接下来是2,由于2还没有出现在后代中,我们将它添加进去,如下图所示。对于第二个父代-后代配对,我们从父代的5开始,这个基因已经存在于后代中,然后移动到4,这个基因也存在,最后是2,它还没有出现,因此被添加到后代中。这一点在下图中也有展示:

图 2.11:OX1 示例 – 第 2 步
对于上面的父代,我们接着处理3(已在后代中),然后是4,这个基因被添加到后代中。对于另一个父代,下一个基因是6。由于它在匹配的后代中尚不存在,因此被添加到后代中。结果如下面的图所示:

图 2.12: OX1 示例 – 步骤 3
我们继续类似地处理下一些尚未出现在后代中的基因,并填补最后可用的位置,如下图所示:

图 2.13: OX1 示例 – 步骤 4
如下图所示,这完成了生成两个有效后代染色体的过程:

图 2.14: OX1 示例 – 步骤 5
实现交叉操作的方法有很多种,其中一些将在本书后面讨论。然而,得益于遗传算法的多样性,你总是可以提出自己的方法。在下一节,我们将讨论突变方法。
突变方法
突变是创造新一代过程中应用的最后一个遗传算子。突变算子应用于通过选择和交叉操作产生的后代。
突变算子是基于概率的,通常以(非常)低的概率发生,因为它有可能损害应用于任何个体的表现。在某些版本的遗传算法中,突变概率会随着代数的推进逐渐增加,以防止停滞并确保种群的多样性。另一方面,如果突变率过高,遗传算法将变成相当于随机搜索的算法。
以下几节将介绍一些常用的突变方法及其典型应用场景。不过,记住你始终可以选择使用自己认为更适合特定用例的、针对问题的突变方法。
翻转位突变
在对二进制染色体应用翻转位突变时,随机选择一个基因并翻转(取反)它的值,如下图所示:

图 2.15: 翻转位突变示例
这可以扩展为多个随机基因的翻转,而不仅仅是一个。
交换突变
在对二进制或基于整数的染色体应用交换突变时,随机选择两个基因并交换它们的值,如下图所示:

图 2.16: 交换突变示例
这种突变操作适用于有序列表的染色体,因为新染色体仍然携带与原染色体相同的基因。
反转突变
在对二进制或基于整数的染色体应用反转突变时,随机选择一组基因并反转该序列中的基因顺序,如下图所示:

图 2.17: 反转突变示例
与交换变异类似,反转变异操作适用于有序列表的染色体。
交换变异
另一种适用于有序列表染色体的变异操作是交换变异。当应用时,会随机选择一段基因序列,并打乱该序列中基因的顺序(或交换),如下所示:

图 2.18:交换变异示例
在下一节中,我们将讨论一些为实数编码遗传算法创建的其他专门操作符。
实数编码遗传算法
到目前为止,我们已经看到表示二进制或整数参数的染色体。因此,遗传操作符适合用于这些类型的染色体。然而,我们经常遇到解空间是连续的问题。换句话说,个体由实数(浮动点数)构成。
历史上,遗传算法使用二进制字符串表示整数和实数;然而,这并不理想。使用二进制字符串表示实数的精度受到字符串长度(位数)的限制。由于我们需要预先确定这个长度,可能会出现二进制字符串过短,导致精度不足,或者过长的情况。
此外,当使用二进制字符串表示数字时,每个位的意义因其位置而异——最重要的位在最左边。这可能导致与模式相关的失衡——即在染色体中出现的模式。例如,模式 1(表示所有以 1 开头的五位二进制字符串)和模式1(表示所有以 1 结尾的五位二进制字符串)都有一个顺序 1 和定义长度 0;然而,第一个模式比第二个模式更具意义。
替代使用二进制字符串,实值数字的数组被发现是更简单且更好的方法。例如,如果我们有一个涉及三个实值参数的问题,染色体看起来像 [x1, x2, x3],其中 x1、x2 和 x3 表示实数,如[1.23, 7.2134, -25.309] 或 [-30.10, 100.2, 42.424]。
本章前面提到的各种选择方法对于实数编码的染色体同样适用,因为它们只依赖个体的适应度,而与表示方式无关。
然而,至今为止我们所讨论的交叉和变异方法不适合实数编码的染色体,因此需要使用专门的方法。一个重要的要点是,这些交叉和变异操作是分别应用于构成实数编码染色体的每个维度的数组。例如,如果[1.23, 7.213, -25.39]和[-30.10, 100.2, 42.42]是已选择用于交叉操作的父代,那么交叉操作将分别应用于以下对:
-
1.23 和-30.10(第一维度)
-
7.213 和 100.2(第二维度)
-
-25.39 和 42.42(第三维度)
这在下图中进行了说明:

图 2.19:实数编码染色体交叉示例
同样,当变异算子应用于实数编码染色体时,它将分别作用于每一维度。
接下来的章节将描述几种实数编码交叉和变异方法。稍后,在 第六章 优化连续函数 中,我们将看到这些方法的实际应用。
混合交叉
在混合交叉(BLX)方法中,每个后代从由父代创建的以下区间中随机选择:
[父代 1 − α(父代 2 − 父代 1),父代 2 + α(父代 2 − 父代 1)]
参数α是一个常数,其值介于 0 和 1 之间。随着α值的增大,区间会变得更宽。
例如,如果父代的值分别为 1.33 和 5.72,则情况如下:
-
α值为 0 时,将生成区间[1.33, 5.72](类似于父代之间的区间)
-
α值为 0.5 时,将生成区间[-0.865, 7.915](是父代之间区间的两倍宽)
-
α值为 1.0 时,将生成区间[-3.06, 10.11](比父代之间的区间宽三倍)
这些示例在下图中进行了说明,父代标记为p1和p2,交叉区间为黄色:

图 2.20:混合交叉示例
使用此交叉方法时,α值通常设置为 0.5。
模拟二进制交叉
模拟二进制交叉(SBX)的思想是模仿常用于二进制编码染色体的单点交叉的特性。其中一个特性是父代值的平均值等于后代值的平均值。
在应用 SBX 时,两个后代是通过以下公式由两个父代生成的:
后代 1 = 1_2 [(1 + β) 父代 1 + (1 − β) 父代 2]
后代 2 = 1_2 [(1 − β) 父代 1 + (1 + β) 父代 2]
这里,β是一个称为扩展因子的随机数。此公式有以下显著特性:
-
两个后代的平均值等于父代的平均值,无论β值如何
-
当β值为 1 时,后代与父代相同
-
当β值小于 1 时,后代比父代更接近彼此
-
当β值大于 1 时,后代比父代之间的距离更远
例如,如果父代的值分别为 1.33 和 5.72,则情况如下:
-
β值为 0.8 时,后代将为 1.769 和 5.281
-
β值为 1.0 时,后代将为 1.33 和 5.72
-
β值为 1.2 时,后代将为 0.891 和 6.159
这些案例在下图中有所展示,其中父代标记为p1和p2,后代标记为o1和o2:

图 2.21:模拟二进制交叉示例
在前述的每种情况中,两个后代的平均值为 3.525,等于两个父代的平均值。
我们还希望保持二进制单点交叉的另一个特性,即后代与父母之间的相似性。这意味着β值的随机分布。β的概率应该在接近 1 的值附近更高,在这些值下,后代与父母相似。为了实现这一点,β值通过另一个随机值 u 计算,u 在区间[0, 1]上均匀分布。一旦选定u的值,β值的计算公式如下:
如果u ≤ 0.5,得到以下结果:β = (2u) 1 _ 1−η
否则,我们得到如下结果:β = [ 1 _ 2(1 − u)] 1 _ 1−η
实变异
在实值编码遗传算法中应用变异的一种选择是用一个全新的、随机生成的实值替换任何实值。然而,这可能会导致变异后的个体与原始个体没有任何关系。
另一种方法是生成一个随机实数,位于原个体的附近。这种方法的一个例子是正态分布(或高斯)变异:使用均值为零、标准差为预定值的正态分布生成一个随机数,如下图所示:

图 2.21:高斯变异分布示例
在接下来的两节中,我们将介绍几个高级话题,即精英策略和生态位分化。
理解精英策略
尽管遗传算法种群的平均适应度通常随着代数的增加而提高,但在任何时候,当前代最优秀的个体可能会丢失。这是因为选择、交叉和变异操作会在创建下一代的过程中改变个体。在许多情况下,这种丧失是暂时的,因为这些个体(或更优秀的个体)将在未来的代中重新引入种群。
然而,如果我们想确保最优秀的个体始终能够进入下一代,可以采用可选的精英策略。这意味着前* n 个个体(n*是一个小的预定参数)会在我们通过选择、交叉和变异创造后代之前,复制到下一代。被复制的精英个体仍然有资格参与选择过程,因此它们仍然可以作为新个体的父母。
精英主义有时可以对算法的性能产生显著的积极影响,因为它避免了在遗传流中重新发现丢失的优秀解所需的潜在时间浪费。
增强遗传算法结果的另一种有趣方法是使用生态位化,下一节将对此进行描述。
生态位化与共享
在自然界中,所有环境进一步划分为多个子环境或生态位,由各种物种组成,这些物种利用各自生态位中的独特资源,如食物和栖息地。例如,森林环境由树顶、灌木丛、森林地面、树根等组成;这些不同部分分别适应不同物种,这些物种专门适应在各自的生态位中生活,并利用该生态位中的资源。
当多个不同物种在同一个生态位中共存时,它们会争夺相同的资源,从而形成一种倾向,即寻找新的、未被占据的生态位并进行填充。
在遗传算法的领域中,这种生态位化现象可以用于维持种群的多样性,并找到多个最优解,每个最优解被视为一个生态位。
例如,假设我们的遗传算法旨在最大化一个具有多个不同高度峰值的适应度函数,如下图所示:

图 2.22:没有生态位化的预期遗传算法结果
随着遗传算法趋向于找到全局最大值,我们预计,经过一段时间后,种群的大部分个体会集中在最高峰值附近。这一点在前面的图中通过函数图上的 × 标记表示,这些标记代表当前代中的个体。
然而,也有一些实现方式,除了寻找全局最大值之外,我们还希望找到其他一些(或所有)峰值。为了实现这一点,我们可以将每个峰值视为一个生态位,提供与其高度成比例的资源。然后,我们找到一种方法,将这些资源在占据这些生态位的个体之间共享(或分配)。理想情况下,这将推动种群按比例分布,最高的峰值吸引最多的个体,因为它提供了最多的奖励,而其他峰值则随着奖励的减少而被越来越少的个体占据。
这种理想情况在以下图中有所描述:

图 2.23:带有生态位化的理想遗传算法结果
现在的挑战是实现这种共享机制。一种实现共享的方式是将每个个体的原始适应度值与(某个函数)所有其他个体的综合距离进行除法。另一种选择是将每个个体的原始适应度值除以其周围一定半径内的其他个体数量。
串行生态位与并行生态位
不幸的是,如前所述,细分概念可能难以实现,因为它增加了适应度计算的复杂性。在实践中,它还要求种群大小是原始种群大小与预期峰值数量的乘积(通常是未知的)。
克服这些问题的一种方法是一次找到一个峰值(串行细分),而不是尝试同时找到所有峰值(并行细分)。为了实现串行细分,我们像往常一样使用遗传算法,找到最佳解。然后,我们更新适应度函数,将找到的最大点区域进行平滑处理,并重复遗传算法的过程。
理想情况下,我们现在将找到下一个最佳峰值,因为原始峰值不再存在。我们可以迭代地重复这个过程,在每次迭代中找到下一个最佳峰值。
使用遗传算法解决问题的艺术
遗传算法为我们提供了一个强大而多用途的工具,可以用来解决各种各样的问题和任务。当我们着手处理一个新问题时,我们需要定制这个工具并使其与问题相匹配。这是通过做出几个选择来实现的,如下文所述。
首先,我们需要确定适应度函数。这是评估每个个体的标准,其中较大的值代表更优秀的个体。该函数不必是数学函数。它可以通过算法、调用外部服务,甚至是游戏结果来表示,举几个例子。我们只需要一种方法,能够程序化地获取任何给定方案(个体)的适应度值。
接下来,我们需要选择一个合适的染色体编码。这基于我们传递给适应度函数的参数。到目前为止,我们见过二进制、整数、有序列表和实数编码的示例。然而,对于一些问题,我们可能需要混合参数类型,甚至决定创建我们自己的自定义染色体编码。
接下来,我们需要选择一个选择方法。大多数选择方法适用于任何类型的染色体。如果适应度函数无法直接访问,但我们仍然能够判断多个候选解中哪个是最好的,我们可以考虑使用锦标赛选择方法。
正如我们在前面的章节中看到的,交叉和变异算子的选择将与个体的染色体编码相关联。二进制编码的染色体与适合实数编码问题的染色体交叉和变异方案会有所不同。与染色体编码的选择类似,在这里,你也可以为交叉和变异设计适合你独特用例的方法。
最后,还有算法的超参数。我们需要设置的最常见参数值如下:
-
种群大小
-
交叉率
-
变异率
-
最大代数
-
其他停止条件
-
优胜劣汰(是否使用;大小)
对于这些参数,我们可以选择我们认为合理的值,然后进行调整,类似于在几乎所有其他优化和学习算法中处理超参数的方式。
如果做出所有这些选择看起来是个压倒性的任务,不用担心!在接下来的章节中,我们将一次又一次地重复这一选择过程,以应对我们将要解决的各种类型的问题。读完本书后,你将能够面对新的问题并做出明智的选择。
总结
在本章中,你被介绍了遗传算法的基本流程。然后我们回顾了该流程的关键组成部分,包括创建种群、计算适应度函数、应用遗传操作符,以及检查停止条件。
接下来,我们讨论了各种选择方法,包括轮盘赌选择、SUS、基于排名的选择、适应度缩放和锦标赛选择,并演示了它们之间的差异。
我们接着回顾了几种交叉方法,包括单点交叉、双点交叉和 k 点交叉,以及 OX1 交叉和部分匹配交叉。
然后,你被介绍了几种变异方法,包括翻转位变异,接着是交换、逆转和扰动变异。
接下来介绍了实数编码遗传算法,以及它们的专用染色体编码和自定义的交叉和变异遗传操作符。
随后介绍了遗传算法中的精英主义、分群和共享等概念。
在本章的最后部分,你被介绍了在使用遗传算法解决问题时需要做出的各种选择,这一过程将在全书中一次次重复。
在下一章,真正的乐趣开始了——用 Python 编程!你将被介绍到 DEAP,一个进化计算框架,可以作为应用遗传算法解决广泛任务的强大工具。在本书的其余部分,我们将使用 DEAP 来开发 Python 程序,解决许多不同的挑战。
进一步阅读
欲了解更多信息,请参考第八章,遗传算法,出自 Prateek Joshi 所著《Python 人工智能》,2017 年 1 月出版,详见subscription.packtpub.com/book/big_data_and_business_intelligence/9781786464392/8。
第二部分:使用遗传算法解决问题
本部分聚焦于使用 Python 将遗传算法应用于各种现实问题,从探索 DEAP 框架开始。我们首先处理基础的 OneMax 问题,展示该框架的能力。接着,我们转向更复杂的组合优化挑战,例如旅行商问题和车辆路径规划问题,并深入探讨约束满足问题,包括 N 皇后问题和护士排班问题。最后,本部分通过将遗传算法应用于连续搜索空间优化,强调先进的技术,如种群划分、共享和有效的约束管理。
本部分包含以下章节:
-
第三章**,使用 DEAP 框架
-
第四章**,组合优化
-
第五章**,约束满足
-
第六章**,优化连续函数
第三章:使用 DEAP 框架
在本章中——如前所述——真正有趣的部分开始了!你将了解Python 中的分布式进化算法(DEAP)——一个强大且灵活的进化计算框架,能够通过遗传算法解决实际问题。经过简要介绍后,你将熟悉其中的两个主要模块——创建器和工具箱——并学习如何创建遗传算法流程所需的各个组件。接下来,我们将编写一个 Python 程序,使用 DEAP 框架解决 OneMax 问题——遗传算法的“Hello World”。然后是一个简化版本的相同程序,我们将利用该框架的内置算法。最后一部分是本章最精彩的部分,我们将尝试调整我们创建的遗传算法的各种设置,并发现这些修改的效果。
本章结束时,你将能够做到以下几点:
-
表达你对 DEAP 框架及其遗传算法模块的熟悉程度
-
理解 DEAP 框架中创建器和工具箱模块的概念
-
将一个简单问题转化为遗传算法表示
-
使用 DEAP 框架创建一个遗传算法解决方案
-
理解如何使用 DEAP 框架的内置算法编写简洁的代码
-
使用 DEAP 框架编写的遗传算法解决 OneMax 问题
-
尝试遗传算法的各种设置,并解读结果中的差异
技术要求
以下是本章的技术要求。
重要提示:
有关技术要求的最新信息,请参考 README 文件:github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/README.md
Python 版本
本书将使用 Python 3,版本 3.11 或更新版本。可以从 Python 软件基金会的官方网站下载 Python:www.python.org/downloads/。更多有用的安装说明可以在这里找到:realpython.com/installing-python/。
使用虚拟环境
在进行基于 Python 的项目时,使用虚拟环境通常是一个好习惯,因为它可以将项目的依赖与其他 Python 项目以及系统现有的设置和依赖隔离开来。
创建虚拟环境的常用方法之一是使用venv,详细说明见:docs.python.org/3/library/venv.html。
另一种流行的 Python 环境和软件包管理方式是使用 conda,详细介绍见此处:conda.io/projects/conda/en/latest/user-guide/tasks/manage-environments.html。
重要提示
使用虚拟环境时,确保在安装所需库之前先激活虚拟环境,如以下部分所述。
安装必要的库
本书将使用 DEAP 库以及其他各种 Python 软件包。有几种方法可以安装这些依赖项,具体方法在以下小节中列出。
使用 requirements.txt
无论你是否选择使用虚拟环境,都可以使用我们提供的 requirements.txt 文件,一次性安装所有必需的依赖项。该文件包含本书中将使用的所有软件包,并可在本书的 GitHub 仓库中找到,链接:github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/requirements.txt。
通常,requirements.txt 文件与 pip 工具一起使用,可以通过以下命令进行安装:
pip install -r /path/to/requirements.txt
安装单个软件包
如果你更喜欢在阅读本书的过程中逐个安装所需的软件包,每章的技术要求部分会提到该章节中将使用的特定软件包。
首先,我们需要安装 DEAP 库。推荐的安装 DEAP 方法是使用 easy_install 或 pip,如下所示:
pip install deap
欲了解更多信息,请查看 DEAP 文档:deap.readthedocs.io/en/master/installation.html。
如果你更倾向于通过 Conda 安装 DEAP,请参阅以下链接:anaconda.org/conda-forge/deap。
此外,本章需要以下软件包:
-
NumPy:
www.numpy.org/ -
Matplotlib:
matplotlib.org/ -
Seaborn:
seaborn.pydata.org/
我们现在准备好使用 DEAP。框架中最有用的工具和实用程序将在接下来的两部分中介绍。但首先,我们将了解 DEAP,并理解为什么我们选择这个框架来处理遗传算法。
本章中将使用的程序可以在本书的 GitHub 仓库中找到,链接:github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/tree/main/chapter_03。
查看以下视频,了解代码的实际应用:packt.link/OEBOd。
DEAP 介绍
正如我们在前面的章节中所看到的,遗传算法和遗传流的基本思想相对简单,许多遗传操作符也是如此。因此,从零开始开发一个程序来实现遗传算法以解决特定问题是完全可行的。
然而,正如在开发软件时常见的那样,使用经过验证的专用库或框架可以让我们的工作变得更加轻松。它帮助我们更快地创建解决方案并减少错误,并且为我们提供了许多现成的选择(以及可以实验的选项),无需重新发明轮子。
已经创建了许多用于遗传算法的 Python 框架——如 PyGAD、GAFT、Pyevolve 和 PyGMO,仅举几例。经过对多个选项的研究,我们选择使用 DEAP 框架,因为它易于使用,功能丰富,且具有良好的扩展性和大量文档支持。
DEAP 是一个 Python 框架,支持使用遗传算法以及其他进化计算技术快速开发解决方案。DEAP 提供了各种数据结构和工具,这些工具在实现基于遗传算法的多种解决方案时至关重要。
DEAP 自 2009 年起在加拿大拉瓦尔大学开发,并且采用 GNU Lesser General Public License (LGPL) 许可证。
DEAP 的源代码可在 github.com/DEAP/deap 上找到,文档可在 deap.readthedocs.io/en/master/ 查看。
使用 creator 模块
DEAP 框架提供的第一个强大工具是 creator 模块。creator 模块用作元工厂,允许我们通过增加新属性来扩展现有的类。
例如,假设我们有一个名为 Employee 的类。通过 creator 工具,我们可以通过创建一个 Developer 类来扩展 Employee 类,如下所示:
from deap import creator
creator.create("Developer", Employee,\
position="Developer", \
programmingLanguages=set)
create() 函数的第一个参数是新类的期望名称。第二个参数是要扩展的现有基类。然后,每个额外的参数定义了新类的一个属性。如果参数被分配了一个数据结构(例如 dict 或 set),它将作为实例属性添加到新类中,并在构造函数中进行初始化。如果参数是一个简单类型,如字面量,它将作为类属性添加,并且在该类的所有实例之间共享。
因此,创建的 Developer 类将扩展 Employee 类,并且将拥有一个类属性 position,其值为 Developer,以及一个实例属性 programmingLanguages,其类型为 set,并在构造函数中进行初始化。因此,实际上,新类等同于以下代码:
class Developer(Employee):
position = "Developer"
def __init__(self):
self.programmingLanguages = set()
重要说明
-
这个新类存在于creator模块中,因此需要引用为creator.Developer。
-
扩展numpy.ndarray类是一个特殊情况,稍后将在本书中讨论。
在使用 DEAP 时,creator模块通常用于创建Fitness类和Individual类,以供遗传算法使用,正如我们接下来将看到的。
创建 Fitness 类
在使用 DEAP 时,适应度值被封装在一个Fitness类中。DEAP 允许将适应度合并为多个组件(也称为目标),每个组件都有自己的权重。这些权重的组合定义了适应度在给定问题中的行为或策略。
定义适应度策略
为了帮助定义这个策略,DEAP 提供了抽象的base.Fitness类,该类包含一个weights元组。这个元组需要被赋值,以定义策略并使类可用。这可以通过使用creator扩展基础的Fitness类来实现,方式类似于我们之前为Developer类所做的:
creator.create("creator.FitnessMax class that extends the base.Fitness class, with the weights class attribute initialized to a value of (1.0,).
Important note
Note the trailing comma in the **weights** definition when a single weight is defined. The comma is required because **weights** is a **tuple**.
The strategy of this `FitnessMax` class is to *maximize* the fitness values of the single-objective solutions throughout the genetic algorithm. Conversely, if we have a single-objective problem where we need to find a solution that *minimizes* the fitness value, we can use the following definition to create the appropriate minimizing strategy:
creator.create("FitnessMin", base.Fitness, weights=(-1.0,))
We can also define a class with a strategy for optimizing more than one objective, and with varying degrees of importance:
creator.create("FitnessCompound", base.Fitness,
creator.FitnessCompound 类,它将使用三个不同的适应度组件。第一个将赋予权重 1.0,第二个赋予 0.2,第三个赋予-0.5。这个适应度策略将倾向于最大化第一个和第二个组件(或目标),并最小化第三个。从重要性来看,第一个组件最重要,其次是第三个组件,最后是第二个组件。
存储适应度值
当weights元组定义了适应度策略时,一个匹配的元组,称为values,用于包含base.Fitness类中的实际适应度值。这些值通过一个单独定义的函数获取,通常称为evaluate(),将在本章后续部分介绍。与weights元组类似,values元组包含每个适应度组件(目标)的一个值。
第三个元组wvalues包含通过将values元组的每个组件与其对应的weights元组组件相乘而得到的加权值。每当设置一个实例的适应度值时,加权值会被计算并插入到wvalues中。这些值在内部用于个体之间的比较操作。
权重适应度值可以使用以下运算符按字典顺序进行比较:
>, <, >=, <=, ==, !=
一旦创建了Fitness类,我们可以在定义Individual类时使用它,如下一小节所示。
创建 Individual 类
creator工具在 DEAP 中的第二个常见用途是定义构成遗传算法种群的个体。正如我们在前几章所看到的,遗传算法中的个体是通过一个染色体表示的,可以通过遗传操作符进行操作。在 DEAP 中,Individual类是通过扩展一个表示染色体的基类来创建的。此外,DEAP 中的每个实例都需要将其适应度函数作为一个属性。
为了满足这两个要求,我们可以利用creator创建creator.Individual类,如下例所示:
creator.create("Individual", list, \
fitness=creator.FitnessMax)
这一行提供了以下两个效果:
-
创建的Individual类扩展了 Python 的list类。这意味着使用的染色体是list类型的。
-
这个Individual类的每个实例都会有一个叫做fitness的属性,属于我们之前创建的FitnessMax类。
我们将在下一节中学习如何使用Toolbox类。
使用 Toolbox 类
DEAP 框架提供的第二种机制是base.Toolbox类。Toolbox用作函数(或操作符)的容器,使我们能够通过别名和自定义现有函数来创建新操作符。
例如,假设我们有一个函数sumOfTwo(),其定义如下:
def sumOfTwo(a, b):
return a + b
使用toolbox,我们现在可以创建一个新的操作符incrementByFive(),它
自定义sumOfTwo()函数,如下所示:
from deap import base
toolbox= base.Toolbox()
toolbox.register("register() toolbox function is the desired name (or alias) for the new operator. The second argument is the existing function to be customized. Then, each additional (optional) argument is automatically passed to the customized function whenever we call the new operator. For example, look at this definition:
toolbox.incrementByFive(10)
Calling the preceding function is equivalent to calling this:
sumOfTwo(10, 5)
This is because the `b` argument has been fixed to a value of `5` by the definition of the `incrementByFive` operator.
Creating genetic operators
In many cases, the `Toolbox` class is used to customize existing functions from the `tools` module. The `tools` module contains numerous handy functions related to the genetic operations of *selection*, *crossover*, and *mutation*, as well as initialization utilities.
For example, the following code defines three aliases that will be later used as genetic operators:
from deap import tools
toolbox.register("select",tools.selTournament,tournsize=3)
toolbox.register("mate", tools.cxTwoPoint)
toolbox.register("mutate", tools.mutFlipBit, indpb=0.02)
The three aliases are defined as follows:
* **select** is registered as an alias to the existing **tools** function, **selTournament()**, with the **tournsize** argument set to **3**. This creates a **toolbox.select** operator that performs *tournament selection* with a tournament size of 3.
* **mate** is registered as an alias to the existing **tools** function, **cxTwoPoint()**. This results in a **toolbox.mate** operator that performs *two-point crossover*.
* **mutate** is registered as an alias to the existing **tools** function, **mutFlipBit**, with the **indpb** argument set to **0.02**, providing a **toolbox.mutate** operator that performs *flip-bit mutation* with 0.02 as the probability for each attribute to be flipped.
The `tools` module provides implementations of various genetic operators, including several of the ones we mentioned in the previous chapter.
`selection.py` file. Some of them are as follows:
* **selRoulette()** implements **roulette** **wheel selection**
* **selStochasticUniversalSampling()** implements **Stochastic Universal** **Sampling** (**SUS**)
* **selTournament()** implements **tournament selection**
`crossover.py` file:
* **cxOnePoint()** implements **single-point crossover**
* **cxUniform()** implements **uniform crossover**
* **cxOrdered()** implements **ordered** **crossover** (**OX1**)
* **cxPartialyMatched()** implements **partially matched** **crossover** (**PMX**)
A couple of the `mutation.py` file are as follows:
* **mutFlipBit()** implements **flip-bit mutation**
* **mutGaussian()** implements **normally** **distributed mutation**
Creating the population
The `init.py` file of the `tools` module contains several functions that can be useful for creating and initializing the population for the genetic algorithm. One particularly useful function is `initRepeat()`, which accepts three arguments:
* The container type in which we would like to put the resulting objects
* The function that’s used to generate objects that will be put into the container
* The number of objects we want to generate
For example, the following line of code will produce a list of 30 random numbers between 0 and 1:
randomList = tools.initRepeat(list 是作为容器填充的类型,random.random 是生成器函数,30 是我们调用函数生成值并填充容器的次数。
如果我们想用整数随机数填充列表,而这些随机数要么是 0,要么是 1,我们可以创建一个函数,利用random.randint()生成一个 0 或 1 的单个随机值,然后将其作为initRepeat()的生成器函数,如下代码片段所示:
def zeroOrOne():
return random.randint(0, 1)
randomList = tools.initRepeat(list, toolbox, as follows:
toolbox.register("zeroOrOne", random.randint, 0, 1)
randomList = tools.initRepeat(list, zeroOrOne()函数,我们创建了 zeroOrOne 操作符(或别名),它调用 random.randint()并使用固定参数 0 和 1。
计算适应度
如前所述,尽管Fitness类定义了决定其策略的适应度权重(如最大化或最小化),实际的适应度值是通过一个单独定义的函数获得的。这个适应度计算函数通常会使用evaluate别名注册到toolbox模块,如下代码片段所示:
def someFitnessCalculationFunction(individual):
return _some_calculation_of_the_fitness
toolbox.register("evaluate",someFitnessCalculationFunction() calculates the fitness for any given individual, while evaluate is registered as its alias.
We are finally ready to put our knowledge to use and solve our first problem using a genetic algorithm written with DEAP. We’ll do this in the next section.
The OneMax problem
The OneMax (or One-Max) problem is a simple optimization task that is often used as the *Hello World* of genetic algorithm frameworks. We will use this problem for the rest of this chapter to demonstrate how DEAP can be used to implement a genetic algorithm.
The OneMax task is to find the binary string of a given length that maximizes the sum of its digits. For example, the OneMax problem of length 5 will consider candidates such as the following:
* 10010 (sum of digits = 2)
* 01110 (sum of digits = 3)
* 11111 (sum of digits = 5)
Obviously (to us), the solution to this problem is always the string that comprises all 1s. However, the genetic algorithm does not have this knowledge and needs to blindly look for the solution using its genetic operators. If the algorithm does its job, it will find this solution, or at least one close to it, within a reasonable amount of time.
The DEAP framework’s documentation uses the OneMax problem as its introductory example ([`github.com/DEAP/deap/blob/master/examples/ga/onemax.py`](https://github.com/DEAP/deap/blob/master/examples/ga/onemax.py)). In the following sections, we will describe our version of DEAP’s OneMax example.
Solving the OneMax problem with DEAP
In the previous chapter, we mentioned several choices that need to be made when solving a problem using the genetic algorithm approach. As we tackle the OneMax problem, we will make these choices in a series of steps. In the chapters to follow, we will keep using the same series of steps as we apply the genetic algorithms approach to various types of problems.
Choosing the chromosome
Since the OneMax problem deals with binary strings, the choice of chromosome is easy – each individual will be represented with a binary string that directly represents a candidate solution. In the actual Python implementation, this will be implemented as a list containing integer values of either 0 or 1\. The length of the chromosome matches the size of the OneMax problem. For example, for a OneMax problem of size 5, the 10010 individual will be represented by `[1, 0, 0,` `1, 0]`.
Calculating the fitness
Since we want to find the individual with the largest sum of digits, we are going to use the `FitnessMax` strategy. As each individual is represented by a list of integer values of either 0 or 1, the fitness value will be directly calculated as the sum of the elements in the list – for example, `sum([1, 0, 0, 1, 0]) =` `2`.
Choosing the genetic operators
Now, we need to decide on the genetic operators to be used – *selection*, *crossover*, and *mutation*. In the previous chapter, we examined several different types of each of these operators. Choosing these genetic operators is not an exact science, and we can usually experiment with several choices. But while selection operators can typically work with any chromosome type, the crossover and mutation operators we choose need to match the chromosome type we use; otherwise, they could produce invalid chromosomes.
For the selection operator, we can start with *tournament* selection because it is simple and efficient. Later, we can experiment with other selection strategies, such as *roulette wheel* selection and *SUS*.
For the *crossover* operator, either the *single-point* or *two-point* crossover operator will be suitable as the result of crossing over two binary strings using these methods will produce a valid binary string.
The *mutation* operator can be the simple *flip-bit* mutation, which works well for binary strings.
Setting the stopping condition
It is always a good idea to put a limit on the number of generations to guarantee that the algorithm doesn’t run forever. This gives us one stopping condition.
In addition, since we happen to know the best solution for the OneMax problem – a binary string with all 1s, and a fitness value equal to the length of the individual – we can use that as a second stopping condition.
Important note
For a real-world problem, we typically don’t have this kind of knowledge in advance.
If either of these conditions is met – that is, the number of generations reaches the limit *or* the best solution is found – the genetic algorithm will stop.
Implementing with DEAP
Putting it all together, we can finally start coding our solution to the OneMax problem using the DEAP framework.
The complete program containing the code snippets shown in this section can be found here: [`github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_03/01_OneMax_long.py`](https://github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_03/01_OneMax_long.py).
Setting up
Before we start the actual genetic algorithm flow, we need to set things up. The DEAP framework has quite a distinct way of doing this, as shown in the rest of this section:
1. We start by importing the essential modules of the DEAP framework, followed by a couple of useful utilities:
```
from deap import base
from deap import creator
from deap import tools
import random
import matplotlib.pyplot as plt
```py
2. Next, we must declare a few constants that set the parameters for the problem and control the behavior of the genetic algorithm:
```
# 问题常量:
ONE_MAX_LENGTH = 100 # 位字符串的长度
# 优化
# 遗传算法常数:
POPULATION_SIZE = 200 # 种群个体数量
# 种群
P_CROSSOVER = 0.9 # 交叉概率
P_MUTATION = 0.1 # 突变概率
# 一个个体
MAX_GENERATIONS = 50 # 最大代数
# 停止条件
```py
3. One important aspect of the genetic algorithm is the use of probability, which introduces a random element to the behavior of the algorithm. However, when experimenting with the code, we may want to be able to run the same experiment several times and get repeatable results. To accomplish this, we must set the random **seed** function to a constant number of some value, as shown in the following code snippet:
```
RANDOM_SEED = 42
random.seed(RANDOM_SEED)
```py
Tip
At some point, you may decide to remove these lines of code, so separate runs could produce somewhat different results.
1. As we saw earlier in this chapter, the **Toolbox** class is one of the main utilities provided by the DEAP framework, enabling us to register new functions (or operators) that customize existing functions using pre-set arguments. Here, we’ll use it to create the **zeroOrOne** operator, which customizes the **random.randomint(a, b)** function. This function normally returns a random integer, **N**, such that **a ≤ N ≤ b**. By fixing the two arguments, **a** and **b**, to the values **0** and **1**, the **zeroOrOne** operator will randomly return either **0** or **1** when it’s called later in the code. The following code snippet defines the **toolbox** variable, and then uses it to register the **zeroOrOne** operator:
```
toolbox = base.Toolbox()
toolbox.register("zeroOrOne", random.randint, 0, 1)
```py
2. Next, we need to create the **Fitness** class. Since we only have one objective here – the sum of digits – and our goal is to maximize it, we’ll choose the **FitnessMax** strategy and use a **weights** tuple with a single positive weight, as shown in the following code snippet:
```
creator.create("FitnessMax", base.Fitness, \
weights=1.0,))
```py
3. in DEAP, the convention is to use a class called **Individual** to represent each of the population’s individuals. This class is created with the help of the **creator** tool. In our case, **list** serves as the base class, which is used as the individual’s chromosome. The class is augmented with the **fitness** attribute, initialized to the **FitnessMax** class that we defined earlier:
```
creator.create("Individual", list, \
fitness=creator.FitnessMax)
```py
4. Next, we must register the `zeroOrOne` operator are integers with random values of `0` or `1`, the resulting `individualCreator` operator will fill an `Individual` instance with 100 randomly generated values of `0` or `1`:
```
toolbox.register("individualCreator",\
tools.initRepeat,\
creator.Individual,\
toolbox.zeroOrOne, ONE_MAX_LENGTH)
```py
5. Lastly, we must register the `initRepeat` – the number of objects we want to generate – is not given here. This means that when using the `populationCreator` operator, this argument will be expected and used to determine the number of individuals that are created – in other words, the population size:
```
toolbox.register("populationCreator", \
tools.initRepeat, \
list, toolbox.individualCreator)
```py
6. To facilitate the fitness calculation (or **evaluation**, in DEAP terminology), we must define a standalone function that accepts an instance of the **Individual** class and returns the fitness for it. Here, we defined a function named **oneMaxFitness** that computes the number of 1s in the individual. Since the individual is essentially a list with values of either **1** or **0**, the Python **sum()** function can be used for this purpose:
```
def oneMaxFitness(individual):
return sum(individual), # 返回一个元组
```py
Tip
As mentioned previously, fitness values in DEAP are represented as tuples, and therefore a comma needs to follow when a single value is returned.
1. Next, we must define the evaluate operator as an alias to the **oneMaxfitness()** function we defined earlier. As you will see later, using the **evaluate** alias to calculate the fitness is a DEAP convention:
```
toolbox.register("tools module and setting the argument values as needed. Here, we chose the following:* 锦标赛选择,锦标赛大小为 3* 单点交叉* 翻转位突变请注意`mutFlipBit`函数的`indpb`参数。该函数会遍历个体的所有属性——在我们的情况下是包含 1 和 0 的列表——每个属性会使用该参数值作为翻转(应用`not`操作符)该属性值的概率。该值独立于突变概率,突变概率由我们之前定义的`P_MUTATION`常量设置,但尚未使用。突变概率用于决定是否对种群中的个体调用`mutFlipBit`函数:
```py
toolbox.register("select",tools.selTournament,\
tournsize=3)
toolbox.register("mate", tools.cxOnePoint)
toolbox.register("mutate", tools.mutFlipBit,\
indpb=1.0/ONE_MAX_LENGTH)
```
```py
We are finally done with our settings and definitions. Now, we’re ready to start the genetic flow, as described in the next section.
Evolving the solution
The genetic flow is implemented in the `main()` function, as described in the following steps:
1. We start the flow by creating the initial population using the **populationCreator** operator we defined earlier, and then using the **POPULATION_SIZE** constant as the argument for this operator. The **generationCounter** variable, which will be used later, is initialized here as well:
```
population = toolbox.populationCreator(n=POPULATION_SIZE)
generationCounter = 0
```py
2. To calculate the fitness for each individual in the initial population, we can use the Python **map()** function to apply the **evaluate** operator to each item in the population. As the **evaluate** operator is an alias for the **oneMaxFitness()** function, the resulting **iterable** consists of the calculated fitness tuple of each individual. It is then converted into a **list** type of tuples:
```
fitnessValues = list(map(toolbox.evaluate,\
population))
```py
3. Since the items of **fitnessValues** match those in the population (which is a list of individuals), we can use the **zip()** function to combine them and assign the matching fitness tuple to each individual:
```
for individual, fitnessValue in zip(population, fitnessValues):
individual.fitness.values = fitnessValue
```py
4. Next, since we have single-objective fitness, we must extract the first value out of each fitness to gather statistics:
```
fitnessValues = [
individual.fitness.values[0] for individual in population
]
```py
5. The statistics that are collected will be the max fitness value and the mean (average) fitness value for each generation. Two lists will be used for this purpose. Let’s create them:
```
maxFitnessValues = []
meanFitnessValues = []
```py
6. We are finally ready for the main loop of the genetic flow. At the top of the loop, we have the stopping conditions. As we decided earlier, one stopping condition will be set by putting a limit on the number of generations, and the other will be set by detecting that we have reached the best solution (a binary string containing all 1s):
```
while max(fitnessValues) < ONE_MAX_LENGTH and \
generationCounter < MAX_GENERATIONS:
```py
7. The generation counter is updated next. It is used by the stopping condition, as well as the **print** statements we will see soon:
```
generationCounter = generationCounter + 1
```py
8. At the heart of the genetic algorithm are the *genetic operators*, which are applied next. The first is the *selection* operator, which can be applied using the **toolbox.select** operator we defined as the *tournament selection* earlier. Since we already set the tournament size when the operator was defined, we only need to send the population and its length as arguments now:
```
offspring = toolbox.select(population, len(population))
```py
9. The selected individuals, now residing in a list called **offspring**, must be cloned so that we can apply the next genetic operators without affecting the original population:
```
offspring = list(map(toolbox.clone, offspring))
```py
Important note
Despite the name **offspring**, these are still clones of individuals from the previous generation, and we still need to mate them using the **crossover** operator to create the actual offspring.
1. The next genetic operator is **crossover**. It was defined as the **toolbox.mate** operator earlier, and is aliasing a single-point crossover. We use Python extended slices to pair every even-indexed item of the **offspring** list with the one following it. Then, we utilize the **random()** function to flip a coin using the *crossover probability* set by the **P_CROSSOVER** constant. This will decide if the pair of individuals will be crossed over or remain intact. Lastly, we delete the fitness values of the children since they have been modified and their existing fitness values are no longer valid:
```
for child1, child2 in zip(offspring[::2], offspring[1::2]):
if random.random() < P_CROSSOVER:
toolbox.mate(child1, child2)
del child1.fitness.values
del child2.fitness.values
```py
Important note
The **mate** function takes two individuals as arguments and modifies them in place, meaning they don’t need to be reassigned.
1. The final genetic operator to be applied is the *mutation*, which we registered earlier as the **toolbox.mutate** operator, and was set to be a *flip-bit* mutation operation. Iterating over all **offspring** items, the mutation operator will be applied at the probability set by the mutation probability constant, **P_MUTATION**. If the individual gets mutated, we must delete its fitness value (if it exists). This value could have carried over with the individual from the previous generation, and after mutation, it is no longer correct and needs to be recalculated:
```
for mutant in offspring:
if random.random() < P_MUTATION:
toolbox.mutate(mutant)
del mutant.fitness.values
```py
2. Individuals that were not crossed over or mutated remained intact, and therefore their existing fitness values, which were already calculated in a previous generation, don’t need to be calculated again. The rest of the individuals will have this value empty. Now, we must find those fresh individuals using the **Fitness** class’ **valid** property, then calculate the new fitness for them similarly to how to did the original calculation for fitness values:
```
freshIndividuals = [
ind for ind in offspring if not ind.fitness.valid]
freshFitnessValues = list(map(toolbox.evaluate,
freshIndividuals))
for individual, fitnessValue in zip(freshIndividuals,
freshFitnessValues
):
individual.fitness.values = fitnessValue
```py
3. Now that the genetic operators are done, it is time to replace the old population with the new one:
```
population[:] = offspring
```py
4. Before we continue to the next round, the current fitness values are collected to allow for statistical gathering. Since the fitness value is a (single element) tuple, we need to select the **[****0]** index:
```
fitnessValues = [ind.fitness.values[0] for ind in population]
```py
5. The max and mean fitness values are then found, at which point their values get appended to the statistics accumulators and a summary line is printed out:
```
maxFitness = max(fitnessValues)
meanFitness = sum(fitnessValues) / len(population)
maxFitnessValues.append(maxFitness)
meanFitnessValues.append(meanFitness)
print(f"- 代数 {generationCounter}:
最大适应度 = {maxFitness}, \
平均适应度 = {meanFitness}")
```py
6. In addition, we must locate the index of the (first) best individual using the max fitness value we just found and print this individual out:
```
best_index = fitnessValues.index(max(fitnessValues))
print("最佳个体 = ", *population[best_index], "\n")
```py
7. Once a stopping condition is activated and the genetic algorithm flow concludes, we can use the statistics accumulators to plot a couple of graphs using the **matplotlib** library. We can use the following code snippet to draw a graph illustrating the progress of the best and average fitness values throughout the generations:
```
plt.plot(maxFitnessValues, color='red')
plt.plot(meanFitnessValues, color='green')
plt.xlabel('代数')
plt.ylabel('最大/平均适应度')
plt.title('代数中的最大与平均适应度')
plt.show()
```py
We are finally ready to test our first genetic algorithm – let’s run it to find out if it finds the OneMax solution.
Running the program
When running the program described in the previous sections, we get the following output:
- 代数 1: 最大适应度 = 65.0, 平均适应度 = 53.575
最佳个体 = 1 1 0 1 0 1 0 0 1 0 0 0 1 1 1 0 1 0 0 1 0 1 0 0 0 1 1 1 1 1 0 1 1 1 1 0 1 0 1 1 1 1 0 0 1 1 111101111101111111000 0 1 0 1 0 1 1 1 0 1 1 0 0 0 1 1 1 0011111111111100
...
- 代数 40: 最大适应度 = 100.0, 平均适应度 = 98.29
最佳个体 = 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 111111111111111111111 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1111111111111111
As you can see, after 40 generations, an *all-1* solution was found, which yielded a fitness of 100 and stopped the genetic flow. The *average fitness*, which started at a value of around 53, ended at a value close to 100.
The graph that’s plotted by `matplotlib` is shown here:

Figure 3.1: Stats of the program solving the OneMax problem
This plot illustrates how max fitness (the red line) increases over generations with incremental leaps, while the average fitness (the green line) keeps progressing smoothly.
Now that we’ve solved the OneMax problem using the DEAP framework, let’s move on to the next section and find out how we can make our code more concise.
Using built-in algorithms
The DEAP framework comes with several built-in evolutionary algorithms provided by the `algorithms` module. One of them, `eaSimple`, implements the genetic algorithm flow we have been using, and can replace most of the code we had in the main method. Other useful DEAP objects, `Statistics` and `Logbook`, can be used for statistics gathering and printing, as we will soon see.
The program described in this section implements the same solution to the OneMax problem as the program discussed in the previous section but with less code. The only differences can be found in the `main` method. We will describe these differences in the following code snippets.
The complete program can be found here: [`github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_03/02_OneMax_short.py`](https://github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_03/02_OneMax_short.py).
The Statistics object
The first change we will make is in the way statistics are being gathered. To this end, we will now take advantage of the `tools.Statistics` class provided by DEAP. This utility enables us to create a `statistics` object using a key argument, which is a function that will be applied to the data on which the statistics are computed:
1. Since the data we plan to provide is the population of each generation, we’ll set the key function to one that extracts the fitness value(s) from each individual:
```
stats = tools.Statistics(lambda ind: ind.fitness.values)
```py
2. We can now register various functions that can be applied to these values at each step. In our example, we only use the **max** and **mean** NumPy functions, but others (such as **min** and **std**) can be registered as well:
```
stats.register("max", numpy.max)
stats.register("avg", numpy.mean)
```py
As we will see soon, the collected statistics will be returned in an object called `logbook` at the end of the run.
The algorithm
Now, it’s time for the actual flow. This is done with a single call to the `algorithms.eaSimple` method, one of the built-in evolutionary algorithms provided by the `algorithms` module of DEAP. When we call the method, we provide it with `population`, `toolbox`, and the `statistics` object, among other parameters:
种群,日志 = 算法.eaSimple(种群, 工具箱,
cxpb=P_CROSSOVER,
mutpb=P_MUTATION,
ngen=MAX_GENERATIONS,
stats=stats,
verbose=True)
The `algorithms.eaSimple` method assumes that we previously used `toolbox` to register the following operators – `evaluate`, `select`, `mate`, and `mutate` – something we did when we created the original program. The stopping condition here is set by the value of `ngen`, which specifies the number of generations to run the algorithm for.
The logbook
When the flow is done, the algorithm returns two objects – the final population and a `logbook` object containing the statistics that were collected. We can now extract the desired statistics from the logbook using the `select()` method so that we can use them for plotting, as we did previously:
maxFitnessValues, meanFitnessValues = logbook.select("max", "avg")
We are now ready to run this slimmer version of the program.
Running the program
When running the program with the same parameter values and settings that we used previously, the printouts will be as follows:
代数 评估次数 最大 平均
0 200 61 49.695
1 193 65 53.575
...
39 192 99 98.04
40 173 100 98.29
...
49 187 100 99.83
50 184 100 99.89
These printouts are automatically generated by the `algorithms.eaSimple` method, according to the way we defined the `statistics` object sent to it, as the `verbose` argument was set to `True`.
The results are numerically similar to what we saw in the previous program, with two differences:
* There is a printout for generation 0; this was not included in the previous program.
* The genetic flow here continues to the 50th generation as this was the only stopping condition. In the previous program, there was an additional stopping condition that stopped the flow at the 40th generation since the best solution (that we happened to know beforehand) was reached.
We can observe the same behavior in the new graph plot. This graph is similar to the one we saw before but it continues to the 50th generation, even though the best result was already reached at the 40th generation:

Figure 3.2: Stats of the program solving the OneMax problem using the built-in algorithm
Consequently, starting at the 40th generation, the value of the best fitness (the red line) no longer changes, while the average fitness (the green line) keeps climbing until it almost reaches the same max value. This means that by the end of this run, nearly all individuals are identical to the best one.
Adding the hall of fame feature
One additional feature of the built-in `algorithms.eaSimple` method is the `tools` module, the `HallOfFame` class can be used to retain the best individuals that ever existed in the population during the evolution, even if they have been lost at some point due to selection, crossover, or mutation. The hall of fame is continuously sorted so that the first element is the first individual that has the best fitness value ever seen.
The complete program containing the code snippets shown in this section can be found here: [`github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_03/03_OneMax_short_hof.py`](https://github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_03/03_OneMax_short_hof.py).
To add the hall of fame functionality, let’s make a few modifications to the previous program:
1. We start by defining a constant for the number of individuals we want to keep in the hall of fame. We will add this line to the constant definition section:
```
HALL_OF_FAME_SIZE = 10
```py
2. Just before calling the **eaSimple** algorithm, we’ll create the **HallOfFame** object with that size:
```
hof = tools.HallOfFame(HALL_OF_FAME_SIZE)
```py
3. The **HallOfFame** object is sent as an argument to the **eaSimple** algorithm, which internally updates it during the run of the genetic algorithm flow:
```
种群,日志 = 算法.eaSimple(\
种群,工具箱,cxpb=P_CROSSOVER, \
mutpb=P_MUTATION, ngen=MAX_GENERATIONS, \
stats=stats, halloffame=hof, verbose=True)
```py
4. When the algorithm is done, we can use the **HallOfFame** object’s **items** attribute to access the list of individuals who were inducted into the hall of fame:
```
print("名人堂个体 = ", *hof.items, sep="\n")
print("最佳历史个体 = ", hof.items[0])
```py
The printed results look as follows – the best individual consists of all 1s, followed by various individuals that have a 0 value in various locations:
```
名人堂个体 =
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
...
```py
5. The best individual is the same one that was printed first previously:
```
最佳个体 = [1, 1, 1, 1, ..., 0, ..., 1]
```py
6. From now on, we will use these features – the **statistics** object and **logbook**, the built-in **eaSimple** algorithm, and **HallOfFame** – in all the programs we create.
Now that we’ve learned how to use the inbuilt algorithms, we’ll experiment with them to find their differences and find the best algorithm for various uses.
Experimenting with the algorithm’s settings
We can now experiment with the various settings and definitions we placed into the program and observe any changes in their behavior and results.
In each of the following subsections, we’ll start from the original program settings and make one or more changes. You are encouraged to experiment with making your own modifications, as well as combining several modifications to be made to the same program.
Bear in mind that the effects of changes we make may be specific to the problem at hand – a simple *OneMax*, in our case – and may be different for other types of problems.
Population size and number of generations
We will start our experimentation by making modifications to the **population size** and the number of generations used by the genetic algorithm:
1. The size of the population is determined by the **POPULATION_SIZE** constant. We will start by increasing the value of this constant from 200 to 400:
```
POPULATION_SIZE = 400
```py
This modification accelerates the genetic flow. The best solution is now found after 22 generations, as shown in the following figure:

Figure 3.3: Stats of the program solving the OneMax problem after increasing the population size to 400
1. Next, we will try reducing the population size to 100:
```
POPULATION_SIZE = 100
```py
2. This modification slows down the convergence of the algorithm, which will no longer reach the best possible value after 50 generations:

Figure 3.4: Stats of the program solving the OneMax problem after decreasing the population size to 100
1. To compensate, let’s try increasing the value of **MAX_GENERATIONS** to 80:
```
MAX_GENERATIONS = 80
```py
2. We find that the best solution is now reached after 68 generations:

Figure 3.5: Stats of the program solving the OneMax problem after increasing the number of generations to 80
This behavior is typical of genetic-algorithm-based solutions – increasing the population will require fewer generations to reach a solution. However, the computational and memory requirements increase with the population size, and we typically aspire to find a moderate population size that will provide a solution within a reasonable amount of time.
Crossover operator
Let’s reset our changes and go back to the original settings (50 generations, population size 200). We are now ready to experiment with the **crossover** operator, which is responsible for creating offspring from parent individuals.
Changing the crossover type from a `mate` operator as follows:
工具箱.register("配对", tools.cxTwoPoint)
The algorithm now finds the best solution after only 27 generations:

Figure 3.6: Stats of the program solving the OneMax problem after switching to a two-point crossover
This behavior is typical of genetic algorithms that utilize binary string representation as two-point crossover provides a more versatile way to combine two parents and mix their genes in comparison to the single-point crossover.
Mutation operator
We will now reset our changes again as we get ready to experiment with the **mutation** operator, which is responsible for introducing random modifications to offspring:
1. We will start by increasing the value of the **P_MUTATION** constant to **0.9**. This results in the following plot:

Figure 3.7: Stats of the program solving the OneMax problem after increasing the mutation probability to 0.9
The results may seem surprising at first as increasing the mutation rate typically causes the algorithm to behave erratically, while here, the effect is seemingly unnoticeable. However, recall that there is another mutation-related parameter in our algorithm, `indpb`, which is an argument of the specific mutation operator we used here – `mutFlipBit`:
工具箱.register("变异", tools.mutFlipBit, \
P_MUTATION 决定个体变异的概率,indpb 决定给定个体中每位的翻转概率。在我们的程序中,我们将 indpb 的值设置为 1.0/ONE_MAX_LENGTH,这意味着在变异解中平均会翻转一位。对于我们的 100 位 OneMax 问题,这似乎限制了变异的效果,无论 P_MUTATION 常数值如何。
-
现在,让我们将indpb的值增加十倍,如下所示:
toolbox.register("mutate", tools.mutFlipBit, \ indpb=10.0/ONE_MAX_LENGTH)使用此值运行算法的结果有些不稳定,如下图所示:

图 3.8:在每位变异概率增加十倍后程序的统计数据
图表显示,尽管一开始算法可以改善结果,但很快陷入振荡状态,无法取得显著改进。
- 进一步增加indpb值至50.0/ONE_MAX_LENGTH,得到如下不稳定的图表:

图 3.9:在每位变异概率增加五十倍后程序的统计数据
正如这张图所示,遗传算法已经变成了等效于随机搜索的过程 – 它可能偶然发现最佳解,但没有向更好的解决方案前进。
选择操作符
接下来,我们将查看selection操作符。首先,我们将改变锦标赛规模以观察该参数与变异概率的综合效果。然后,我们将考虑使用轮盘选择而不是锦标赛选择。
锦标赛规模及其与变异概率的关系
再次,我们将从程序的原始设置开始,进行新的修改和实验之前:
-
首先,我们将修改锦标赛选择算法的tournamentSize参数,并将其改为2(而不是原来的3):
toolbox.register("select", tools.selTournament, tournsize=2)这似乎对算法的行为没有明显影响:

图 3.10:在将锦标赛规模减少至 2 后程序解决 OneMax 问题的统计数据
-
如果我们将锦标赛规模增加到一个非常大的值,比如100,会发生什么?
-
让我们看看:
toolbox.register("select", tools.selTournament, tournsize=100)该算法仍然表现良好,并在不到 40 代中找到了最佳解。一个显著的效果是最大适应度现在与平均适应度非常接近,如下图所示:

图 3.11:在将锦标赛规模增加至 100 后程序的统计数据
这种行为发生的原因是,当锦标赛规模增大时,弱个体被选中的机会减少,较好的解法倾向于占据整个种群。在实际问题中,这种占据可能导致次优解充斥种群,从而阻止最佳解的出现(这种现象称为过早收敛)。然而,在简单的 OneMax 问题中,这似乎不是问题。一个可能的解释是,突变操作符提供了足够的多样性,确保解法朝着正确的方向前进。
-
为了验证这个解释,我们将突变概率降低十倍,设为0.01:
P_MUTATION = 0.01如果我们再次运行算法,我们会看到结果在算法开始后很快停止改善,然后以更慢的速度改进,偶尔会有一些提升。整体结果比上一次运行差得多,因为最佳适应度大约是 80,而不是 100:

图 3.12:程序统计,锦标赛规模为 100,突变概率为 0.01
这一解释是,由于锦标赛规模较大,初始种群中的最佳个体在少数几代内就占据了主导地位,这在图表的初期迅速增长中得到了体现。之后,只有偶尔出现正确方向的突变——即将 0 翻转为 1——才能产生更好的个体;这一点在图表中通过红线的跃升表示。很快,这个个体又会占据整个种群,绿线会追赶上红线。
-
为了使这种情况更加极端,我们可以进一步降低突变率:
P_MUTATION = 0.001我们现在可以看到相同的一般行为,但由于突变非常罕见,改进也很少且间隔较长:

图 3.13:程序统计,锦标赛规模为 100,突变概率为 0.001
- 现在,如果我们将代数增加到 500 代,我们可以更清楚地看到这种行为:

图 3.14:程序统计,锦标赛规模为 100,突变概率为 0.001,经过 500 代
-
出于好奇,我们将锦标赛规模再次调整为3,并将代数恢复为50代,同时保持较小的突变率:
MAX_GENERATIONS = 50 toolbox.register("select", tools.selTournament, tournsize=3)最终的图表与原始图表更为接近:

图 3.15:程序在使用锦标赛规模为 3 和突变概率为 0.001 的情况下,在 50 代中的统计数据
在这里,似乎也发生了接管现象,但发生得较晚,大约是在第 30 代左右,当时最好的适应度已经接近 100 的最大值。在这种情况下,使用更合理的突变率将帮助我们找到最佳解决方案,就像在原始设置中发生的那样。
轮盘赌选择
让我们再一次回到原始设置,准备进行最后的实验,因为我们将尝试用轮盘赌选择替换锦标赛选择算法,这在第二章中有描述,理解遗传算法的关键组件。操作步骤如下:
toolbox.register("select", tools.selRoulette)
这一变化似乎对算法的结果造成了负面影响。正如下面的图所示,在多个时刻,由于选择过程的影响,最佳解决方案被遗忘,并且最大适应度值至少在短期内下降,尽管平均适应度值仍在上升。这是因为轮盘赌选择算法以与适应度成比例的概率选择个体;当个体之间的差异较小的时候,较弱个体被选中的机会更大,这与我们之前使用的锦标赛选择方法不同:

图 3.16:使用轮盘赌选择的程序统计数据
为了弥补这种行为,我们可以使用在第二章中提到的精英主义方法,理解遗传算法的关键组件。该方法允许当前世代中最优秀的个体直接转移到下一代,并且保持不变,从而避免它们的丧失。在下一章中,我们将探讨在使用 DEAP 库时应用精英主义方法。
小结
在本章中,您介绍了 creator 和 toolbox 模块,以及如何使用它们来创建遗传算法流程所需的各种组件。接着,我们使用 DEAP 编写了两个版本的 Python 程序来解决 OneMax 问题,第一个版本是完整实现遗传算法流程,第二个版本则更简洁,利用了框架中内置的算法。第三个版本引入了 DEAP 提供的 HOF 特性。然后,我们通过不同的遗传算法设置进行实验,发现了改变种群大小、以及修改 选择、交叉 和 突变 运算符的效果。
在下一章中,基于本章的内容,我们将开始解决实际的组合优化问题,包括旅行商问题和车辆路径规划问题,并使用基于 DEAP 的 Python 程序来实现。
进一步阅读
如需更多信息,请参考以下资源:
-
DEAP 文档:
deap.readthedocs.io/en/master/ -
DEAP 源代码在 GitHub 上:
github.com/DEAP/deap
第四章:组合优化
在本章中,你将学习如何在组合优化应用中利用遗传算法。我们将首先描述搜索问题和组合优化,并概述几个组合优化问题的实际操作示例。接下来,我们将分析这些问题,并通过使用 DEAP 框架的 Python 解决方案进行匹配。我们将涉及的优化问题包括著名的背包问题、旅行商问题(TSP)和车辆调度问题(VRP)。此外,我们还将讲解基因型到表型的映射以及探索与开发的主题。
到本章结束时,你将能够完成以下任务:
-
理解搜索问题和组合优化的性质
-
使用 DEAP 框架编写的遗传算法解决背包问题
-
使用 DEAP 框架编写的遗传算法解决旅行商问题(TSP)
-
使用 DEAP 框架编写的遗传算法解决车辆调度问题(VRP)
-
理解基因型到表型的映射
-
熟悉探索与开发的概念,并了解它与精英主义的关系
技术要求
在本章中,我们将使用 Python 3,并辅以以下支持库:
-
deap
-
numpy
-
matplotlib
-
seaborn
重要提示
如果你使用提供的requirements.txt文件(见第三章),这些库将已经安装在你的环境中。
此外,我们还将使用来自Rosetta Code(rosettacode.org/wiki/Rosetta_Code)和TSPLIB(comopt.ifi.uni-heidelberg.de/software/TSPLIB95/)网页的基准数据。
本章将使用的程序可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/tree/main/chapter_04。查看以下视频,观看代码演示:packt.link/OEBOd
搜索问题和组合优化
遗传算法应用的一个常见领域是搜索问题,这些问题在物流、运营、人工智能和机器学习等领域具有重要应用。示例包括确定包裹投递的最佳路线、设计基于枢纽的航空网络、管理投资组合,以及将乘客分配给出租车车队中的可用司机。
搜索算法侧重于通过有序评估 状态 和 状态转移 来解决问题,目的是找到从初始状态到期望的最终(或“目标”)状态的路径。通常,每个状态转移都会涉及一个 成本 或 收益,相应的搜索算法目标是找到一条最小化成本或最大化收益的路径。由于最优路径只是众多可能路径中的一种,这类搜索与 组合优化 相关,组合优化涉及从一个有限但通常极为庞大的可能对象集合中找到最优解。
这些概念将在我们熟悉 背包问题 时得到说明,背包问题是下一节的主要内容。
解决背包问题
想象一个熟悉的场景——为一次长途旅行打包。你有许多想带上的物品,但受限于行李箱的容量。在你心中,每个物品都有一个它会给旅行带来的特定价值;同时,它也有与之相关的大小(和重量),并且它会与其他物品争夺行李箱中的有限空间。这种情况就是 背包问题 的一种现实生活中的例子,背包问题被认为是最古老、最被研究的组合搜索问题之一。
更正式地说,背包问题由以下几个部分组成:
-
一组 物品,每个物品都有一个特定的 价值 和 重量
-
一个具有特定 重量容量 的 包/袋/容器(“背包”)
我们的目标是选出一组物品,使得它们的总价值最大,同时不超过背包的总重量容量。
在搜索算法的背景下,每个物品的子集代表一个状态,所有可能的物品子集的集合被称为状态空间。对于一个具有 n 个物品的背包 0-1 问题,状态空间的大小为 2^n,随着 n 值的增加,状态空间的大小会迅速增大,即使是一个适中的 n 值也会如此。
在这个(原始)版本的背包问题中,每个物品只能被选择一次,或者根本不被选择,因此它有时被称为 背包 0-1 问题。然而,它可以扩展到其他变体——例如,物品可以多次(有限次或无限次)被选中,或者存在多个具有不同容量的背包。
背包问题的应用出现在许多实际过程当中,涉及资源分配和决策制定,例如在构建投资组合时选择投资、切割原材料时最小化浪费、以及在限时测试中选择哪些问题来解答,从而“花最少的钱得到最大的回报”。
为了更好地理解背包问题,我们将看一个广为人知的例子。
Rosetta Code 背包 0-1 问题
Rosetta Code网站(rosettacode.org)提供了一系列编程任务,每个任务都有多种语言的解决方案。其中一个任务描述了rosettacode.org/wiki/Knapsack_problem/0-1中的背包 0-1 问题,任务要求一个游客决定在周末旅行中应该带哪些物品。游客可以从 22 个物品中选择,每个物品都有一个价值,表示它对即将进行的旅行的重要性。
该问题中游客背包的重量容量为400。物品列表及其对应的价值和重量列在下表中:
| 物品 | 重量 | 价值 |
|---|---|---|
map |
9 | 150 |
compass |
13 | 35 |
water |
153 | 200 |
sandwich |
50 | 160 |
glucose |
15 | 60 |
tin |
68 | 45 |
banana |
27 | 60 |
apple |
39 | 40 |
cheese |
23 | 30 |
beer |
52 | 10 |
suntan cream |
11 | 70 |
camera |
32 | 30 |
T-shirt |
24 | 15 |
trousers |
48 | 10 |
umbrella |
73 | 40 |
waterproof trousers |
42 | 70 |
waterproof overclothes |
43 | 75 |
note-case |
22 | 80 |
sunglasses |
7 | 20 |
towel |
18 | 12 |
socks |
4 | 50 |
book |
30 | 10 |
表 4.1:Rosetta Code 背包 0-1 物品列表
在我们开始解决这个问题之前,需要讨论一个重要问题——什么是潜在的解决方案?
解决方案表示
在解决背包 0-1 问题时,一种直接表示解决方案的方法是使用一个二进制值列表。该列表中的每个条目对应问题中的一个物品。对于 Rosetta Code 问题,解决方案可以通过一个包含 22 个整数值为0或1的列表表示。值为1表示选择了相应的物品,而值为0则意味着该物品没有被选中。在应用遗传算法方法时,这个二进制值列表将作为染色体使用。
然而,我们必须记住,所选择物品的总重量不能超过背包的容量。将这个限制纳入解决方案的一种方法是,等到解决方案进行评估时再考虑这一点。然后,通过逐一添加所选物品的重量来进行评估,同时忽略任何会导致累积重量超过最大允许值的物品。从遗传算法的角度来看,这意味着个体的染色体表示(基因型)在转换为实际解决方案(表现型)时可能无法完全表达自己,因为染色体中的一些1值可能会被忽略。这种情况有时被称为基因型到表现型的映射。
我们刚才讨论的解决方案表示法在下一个小节中描述的 Python 类中得到了实现。
Python 问题表示
为了封装 Rosetta Code 的 0-1 背包问题,我们创建了一个名为Knapsack01Problem的 Python 类。这个类包含在knapsack.py文件中,文件可以在github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_04/knapsack.py找到。
该类提供了以下方法:
-
__init_data():此方法通过创建一个元组列表来初始化RosettaCode.org的 0-1 背包问题数据。每个元组包含一个物品的名称,后面跟着它的重量和价值。
-
getValue(zeroOneList):此方法计算列表中所选物品的价值,同时忽略那些会导致累计重量超过最大重量的物品。
-
printItems(zeroOneList):此方法打印出列表中选择的物品,同时忽略那些会导致累计重量超过最大重量的物品。
类的main()方法创建了一个Knapsack01Problem类的实例。然后它创建一个随机解并打印出相关信息。如果我们将这个类作为独立的 Python 程序运行,示例输出可能如下所示:
Random Solution =
[1 1 1 1 1 0 0 0 0 1 1 1 0 1 0 0 0 1 0 0 0 0]
- Adding map: weight = 9, value = 150, accumulated weight = 9, accumulated value = 150
- Adding compass: weight = 13, value = 35, accumulated weight = 22, accumulated value = 185
- Adding water: weight = 153, value = 200, accumulated weight = 175, accumulated value = 385
- Adding sandwich: weight = 50, value = 160, accumulated weight = 225, accumulated value = 545
- Adding glucose: weight = 15, value = 60, accumulated weight = 240, accumulated value = 605
- Adding beer: weight = 52, value = 10, accumulated weight = 292, accumulated value = 615
- Adding suntan cream: weight = 11, value = 70, accumulated weight = 303, accumulated value = 685
- Adding camera: weight = 32, value = 30, accumulated weight = 335, accumulated value = 715
- Adding trousers: weight = 48, value = 10, accumulated weight = 383, accumulated value = 725
- Total weight = 383, Total value = 725
请注意,在随机解中最后一次出现的1代表了note-case物品,这个物品遭遇了前一小节中讨论的基因型到表型的映射。由于该物品的重量为 22,它会导致总重量超过 400。因此,这个物品没有包含在解中。
这个随机解,正如你所预期的,远非最优解。接下来,让我们尝试通过遗传算法找到这个问题的最优解。
遗传算法解决方案
为了解决我们的 0-1 背包问题,我们使用遗传算法创建了一个名为01-solve-knapsack.py的 Python 程序,该程序位于github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_04/01_solve_knapsack.py。
提醒一下,我们决定在这里使用的染色体表示法是一个包含 0 或 1 值的整数列表。从遗传算法的角度来看,这使得我们的问题类似于前一章中我们解决的 OneMax 问题。遗传算法并不关心染色体表示的是什么(也称为表现型)——可能是一个要打包的物品列表,一组布尔方程的系数,或者一个实际的二进制数;它关心的只是染色体本身(基因型)以及该染色体的适应度值。将染色体映射到其代表的解是由适应度评估函数完成的,该函数在遗传算法之外实现。在我们的案例中,这个染色体映射和适应度计算是通过getValue()方法实现的,该方法被封装在Knapsack01Problem类中。
所有这些的结果是,我们可以使用与之前解决 One-Max 问题相同的遗传算法实现,只需要做一些适配。
以下步骤描述了我们解决方案的主要要点:
-
首先,我们需要创建一个我们希望解决的背包问题实例:
knapsack = knapsack.Knapsack01Problem() -
然后,我们必须指示遗传算法使用该实例的getValue()方法进行适应度评估:
def knapsackValue(individual): return knapsack.getValue(individual), toolbox.register("evaluate", knapsackValue) -
使用的遗传算子与二进制列表染色体兼容:
toolbox.register("select", tools.selTournament, tournsize=3) toolbox.register("mate", tools.cxTwoPoint) toolbox.register("mutate", tools.mutFlipBit, indpb=1.0/len(knapsack)) -
一旦遗传算法停止,我们可以使用printItems()方法打印出找到的最佳解:
best = hof.items[0] print("-- Knapsack Items = ") knapsack.printItems(best) -
我们还可以调整遗传算法的某些参数。由于这个特定问题使用的是长度为 22 的二进制字符串,它似乎比我们之前解决的 100 长度的 OneMax 问题要简单,因此我们可能可以减少人口规模和最大代数。
在运行算法 50 代后,人口规模为 50 时,我们得到以下结果:
-- Best Ever Individual = [1, 1, 1, 1, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 1, 1] -- Best Ever Fitness = 1030.0 -- Knapsack Items = - Adding map: weight = 9, value = 150, accumulated weight = 9, accumulated value = 150 - Adding compass: weight = 13, value = 35, accumulated weight = 22, accumulated value = 185 - Adding water: weight = 153, value = 200, accumulated weight = 175, accumulated value = 385 - Adding sandwich: weight = 50, value = 160, accumulated weight = 225, accumulated value = 545 - Adding glucose: weight = 15, value = 60, accumulated weight = 240, accumulated value = 605 - Adding banana: weight = 27, value = 60, accumulated weight = 267, accumulated value = 665 - Adding suntan cream: weight = 11, value = 70, accumulated weight = 278, accumulated value = 735 - Adding waterproof trousers: weight = 42, value = 70, accumulated weight = 320, accumulated value = 805 - Adding waterproof overclothes: weight = 43, value = 75, accumulated weight = 363, accumulated value = 880 - Adding note-case: weight = 22, value = 80, accumulated weight = 385, accumulated value = 960 - Adding sunglasses: weight = 7, value = 20, accumulated weight = 392, accumulated value = 980 - Adding socks: weight = 4, value = 50, accumulated weight = 396, accumulated value = 1030 - Total weight = 396, Total value = 1030
1030的总值是该问题的已知最优解。
这里,我们也可以看到,最佳个体染色体中表示book项的最后一个 1 的出现被牺牲,以便在映射到实际解时保持累计重量不超过 400 的限制。
以下图表显示了代际中的最大和平均适应度,表明最优解在不到 10 代的情况下就找到了:

图 4.1:解决背包 0-1 问题的程序统计数据
在下一部分,我们将转向一个更复杂,但仍是经典的组合搜索任务,称为 TSP。
解决 TSP
假设你管理一个小型配送中心,需要用一辆车向一系列客户配送包裹。为了访问所有客户并返回起点,车辆应该走哪条最佳路线?这是经典的TSP问题的一个例子。
TSP 起源于 1930 年,从那时起,它成为了最深入研究的优化问题之一。它常常被用来作为优化算法的基准。这个问题有许多变种,但最初是在描述一个旅行商需要进行一个覆盖多个城市的旅行时提出的:
“给定一个城市列表和每对城市之间的距离,找到一条尽可能短的路径,这条路径需要经过所有城市并最终返回起点城市。”
使用组合数学,你可以发现,当给定n个城市时,经过所有城市的可能路径数量是(n − 1)! / 2。
下图展示了覆盖德国 15 个最大城市的旅行商问题的最短路径:

图 4.2:德国 15 个最大城市的最短 TSP 路径。
来源: commons.wikimedia.org/wiki/File:TSP_Deutschland_3.png。
图片来源:Kapitän Nemo。
在这种情况下,n=15,所以可能的路径数量是14!/2,这将是一个惊人的数字——43,589,145,600。
在搜索算法的背景下,每一条路径(或部分路径)代表一个状态,所有可能路径的集合被认为是状态空间。每条路径都有一个对应的“成本”——路径的长度(距离)——我们正在寻找的是能够最小化该距离的路径。
正如我们所指出的,即使对于适度数量的城市,状态空间也非常庞大,这可能使得评估每一条可能路径变得极其昂贵。因此,尽管相对容易找到一条经过所有城市的路径,找到最优路径却可能非常困难。
TSPLIB 基准测试文件
TSPLIB是一个包含基于城市实际地理位置的 TSP 样本问题的库。该库由海德堡大学维护,相关示例可以在此找到:comopt.ifi.uni-heidelberg.de/software/TSPLIB95/tsp/。
在这个网页上可以找到两种类型的文件:带有.tsp.gz后缀的文件,每个文件描述了一个特定的 TSP 问题,以及对应的.opt.tour.gz文件,包含每个问题的最优解。
问题描述文件是基于文本的,使用空格分隔。典型的文件包含几行信息,然后是城市数据。我们感兴趣的文件是包含参与城市的 x、y 坐标的文件,这样我们可以绘制城市并可视化它们的位置。例如,解压后的burma14.tsp.gz文件内容如下(此处省略了一些行以便简洁):
NAME: burma14
TYPE: TSP
...
NODE_COORD_SECTION
1 16.47 96.10
2 16.47 94.44
3 20.09 92.54
...
12 21.52 95.59
13 19.41 97.13
14 20.09 94.55
EOF
对我们来说,最有趣的部分是NODE_COORD_SECTION和EOF之间的行。在一些文件中,DISPLAY_DATA_SECTION被用来代替NODE_COORD_SECTION。
我们准备好解决一个示例问题了吗?好吧,在我们开始之前,我们仍然需要弄清楚如何表示潜在解。这将在下一小节中解决。
解的表示
在解决 TSP 时,城市通常用从 0 到 n-1 的数字表示,可能的解将是这些数字的序列。例如,一个有五个城市的问题,可能的解包括[0, 1, 2, 3, 4]、[2, 4, 3, 1, 0],依此类推。每个解可以通过计算并汇总每两个连续城市之间的距离,然后加上最后一个城市到第一个城市的距离来进行评估。因此,在应用遗传算法解决这个问题时,我们可以使用类似的整数列表来作为染色体。
下一小节中描述的 Python 类读取 TSPLIB 文件的内容,并计算每两个城市之间的距离。此外,它还使用我们刚才讨论的列表表示法,计算给定潜在解的总距离。
Python 问题表示
为了封装 TSP 问题,我们创建了一个名为TravelingSalesmanProblem的 Python 类。该类包含在tsp.py文件中,您可以在github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_04/tsp.py找到它。
该类提供以下私有方法:
-
__create_data():该方法读取所需的 TSPLIB 文件,提取所有城市的坐标,计算每两个城市之间的距离,并用它们填充一个距离矩阵(二位数组)。然后,它使用pickle工具将城市位置和计算出的距离序列化到磁盘中。
-
__read_data():该方法读取序列化数据,如果数据不可用,则调用__create_data()进行准备。
这些方法由构造函数内部调用,因此数据在实例创建时就会初始化。
此外,该类还提供以下公共方法:
-
getTotalDistance(indices):该方法计算由给定城市索引列表描述的路径的总距离。
-
plotData(indices):该方法绘制由给定城市索引列表描述的路径。
该类的主要方法执行了之前提到的类方法:首先,它创建了bayg29问题(巴伐利亚的 29 个城市),然后计算了硬编码的最优解的距离(如匹配的.opt.tour文件中所述),最后绘制出来。因此,如果我们将此类作为独立的 Python 程序运行,输出将如下所示:
Problem name: bayg29
Optimal solution = [0, 27, 5, 11, 8, 25, 2, 28, 4, 20, 1, 19, 9, 3, 14, 17, 13, 16, 21, 10, 18, 24, 6, 22, 7, 26, 15, 12, 23]
Optimal distance = 9074.147
最优解的图如下所示:

图 4.3:“bayg29” TSP 的最优解的图示。红色的点代表城市
接下来,我们将尝试使用遗传算法达到这个最优解。
遗传算法解决方案
对于我们第一次尝试使用遗传算法解决 TSP,我们创建了02-solve-tsp-first-attempt.py Python 程序,位于github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_04/02_solve_tsp_first_attempt.py。
我们解决方案的主要部分如下所述:
-
程序通过创建bayg29问题的实例开始,如下所示:
TSP_NAME = "bayg29" tsp = tsp.TravelingSalesmanProblem(TSP_NAME) -
接下来,我们需要定义适应度策略。在这里,我们希望最小化距离,这意味着一个单目标最小化的fitness类,该类通过一个单一的负权重来定义:
creator.create("[0, 27, 5, 11, 8, 25, 2, 28, 4, 20, 1, 19, 9, 3, 14, 17, 13, 16, 21, 10, 18, 24, 6, 22, 7, 26, 15, 12, 23]The following code snippet is responsible for implementing this chromosome. It’s explained further after:creator.create("Individual", array.array, typecode='i',
fitness=creator.FitnessMin)
toolbox.register("randomOrder", random.sample,
range(len(tsp)), len(tsp))
toolbox.register("individualCreator", tools.initIterate,
creator.Individual, toolbox.randomOrder)
toolbox.register("populationCreator", tools.initRepeat, list,
toolbox.individualCreator)
The `Individual` class is created first, extending an array of integers and augmenting it with the `FitnessMin` class.The `randomOrder` operator is then registered to provide the results of `random.sample()` invocation over a range defined by the length of the TSP problem (the number of cities, or *n*). This will result in a randomly generated list of indices between 0 and *n-1*.The `IndividualCreator` operator is created next. When called, it will invoke the `randomOrder` operator and iterate over the results to create a valid chromosome consisting of the city indices.The last operator, `populationCreator`, is created to produce a list of individuals using the `IndividualCreator` operator. -
现在,染色体已经实现,是时候定义适应度评估函数了。此操作由tspDistance()函数完成,它直接利用TravelingSalesmanProblem类的getTotalDistance()方法:
def tpsDistance(individual): return tsp.getTotalDistance(individual), # return a tuple toolbox.register("evaluate", tpsDistance) -
接下来,我们需要定义遗传操作符。对于选择操作符,我们可以使用锦标赛选择,锦标赛的大小为3,就像我们在之前的案例中所做的那样:
toolbox.register("select", tools.selTournament, tournsize=3) -
然而,在选择交叉和变异操作符之前,我们需要记住我们使用的染色体不仅仅是一个整数列表,而是一个表示城市顺序的索引列表(或有序列表),因此我们不能仅仅混合两个列表的部分或随意改变列表中的索引。相反,我们需要使用专门设计的操作符,这些操作符旨在生成有效的索引列表。在第二章,理解遗传算法的关键组成部分中,我们探讨了其中的几个操作符,包括有序交叉和打乱变异。在这里,我们使用的是 DEAP 对这些操作符的相应实现,cxOrdered和mutShuffleIndexes:
toolbox.register("mate", tools.cxOrdered) toolbox.register("mutate", tools.mutShuffleIndexes, indpb=1.0/len(tsp)) -
最后,是时候调用遗传算法流程了。在这里,我们使用默认的 DEAP 内置eaSimple算法,并使用默认的stats和halloffame对象来提供我们稍后可以展示的信息:
population, logbook = algorithms.eaSimple(population, \ toolbox, cxpb=P_CROSSOVER, mutpb=P_MUTATION, \ ngen=MAX_GENERATIONS, stats=stats, halloffame=hof, \ verbose=True)
使用文件顶部出现的常量值运行此程序(种群大小为 300,200 代,交叉概率为 0.9,突变概率为 0.1),会得到以下结果:
-- Best Ever Individual = Individual('i', [0, 27, 11, 5, 20, 4, 8, 25, 2, 28, 1, 19, 9, 3, 14, 17, 13, 16, 21, 10, 18, 12, 23, 7, 26, 22, 6, 24, 15])
-- Best Ever Fitness = 9549.9853515625
所找到的最佳适应度(9549.98)与已知的最佳距离 9074.14 相差不远。
程序随后生成了两个图。第一个图展示了在运行过程中找到的最佳个体的路径:

图 4.4:第一个程序尝试解决“bayg29”TSP 问题时找到的最佳解的图
第二个图显示了遗传流的统计数据。注意,这次我们选择收集最小适应度值的数据,而不是最大值,因为该问题的目标是最小化距离:

图 4.5:第一个程序尝试解决“bayg29”TSP 问题时的统计数据
现在我们已经找到了一个不错的解,但还不是最优解,我们可以尝试找出改进结果的方法。例如,我们可以尝试改变种群大小、代数和概率。我们还可以用其他兼容的遗传算子替代当前的遗传算子。我们甚至可以更改设置的随机种子,看看结果有何变化,或者使用不同的种子进行多次运行。在下一节中,我们将尝试结合精英主义和增强探索来改进我们的结果。
通过增强探索和精英主义来改进结果
如果我们尝试增加前一个程序中的代数,我们会发现解并没有改进——它停留在了大约 200 代之前达到的(稍微)次优解中。这一点在以下图中得到了展示,该图展示了 500 代的情况:

图 4.6:第一个程序运行 500 代的统计数据
从那时起,平均值与最佳值之间的相似性表明该解已经主导了种群,因此,除非出现幸运的突变,否则我们将不会看到任何改进。在遗传算法的术语中,这意味着开发已经压倒了探索。开发通常意味着利用当前可得的结果,而探索则强调寻找新的解决方案。两者之间的微妙平衡可以带来更好的结果。
增加探索的一个方法是将用于锦标赛选择的锦标赛大小从 3 减小到 2:
toolbox.register("select", tools.selTournament, tournsize=2)
正如我们在第二章《理解遗传算法的关键组成部分》中讨论的那样,这将增加选中较不成功个体的几率。这些个体可能带有更好的未来解的关键。然而,如果我们在做了这个更改后运行相同的程序,结果却远远不如预期——最佳适应度值超过了 13,000,而最佳解的图形如下所示:

图 4.7:程序在锦标赛大小减少到 2 时找到的最佳解的图形
这些不理想的结果可以通过统计图来解释:

图 4.8:程序的统计数据,锦标赛大小减少到 2
该图说明了我们无法保留最好的解。正如“嘈杂”的图形所示,图形在更好的值和更差的值之间不断波动,好的解容易因为更宽松的选择机制而迅速“丢失”,这种机制往往允许较差的解被选中。这意味着我们让探索过度进行,为了平衡这一点,我们需要重新引入一定的开发度。这可以通过使用精英主义机制来实现,精英主义首次在第二章中介绍。
精英主义使我们能够保持最好的解不变,允许它们在遗传过程中“跳过”选择、交叉和变异的遗传操作。为了实现精英主义,我们需要“深入底层”,修改 DEAP 的algorithms.eaSimple()算法,因为该框架没有提供直接跳过这三种操作的方法。
修改后的算法,称为eaSimpleWithElitism(),可以在elitism.py文件中找到,文件位置在github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_04/elitism.py。
eaSimpleWithElitism()方法与原始的eaSimple()类似,唯一的修改是现在使用halloffame对象来实现精英机制。halloffame对象中包含的个体会直接被注入到下一代,并且不受选择、交叉和变异的遗传操作影响。这本质上是以下修改的结果:
-
选择的个体数量不再等于种群大小,而是通过减少精英个体的数量来调整:
offspring = toolbox.select(population, len(population) - hof_size) -
在应用了遗传操作后,精英个体被重新加入到种群中:
offspring.extend(halloffame.items)
我们现在可以将对 algorithms.eaSimple() 的调用替换为对 elitism.eaSimpleWithElitism() 的调用,而不改变任何参数。然后,我们将 HALL_OF_FAME_SIZE 常量设置为 30,这意味着我们将始终保持种群中最优秀的 30 个个体。
修改后的 Python 程序 03-solve-tsp.py 可以在 github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_04/03_solve_tsp.py 找到。
运行这个新程序后,我们现在能够达到最优解:
-- Best Ever Individual = Individual('i', [0, 23, 12, 15, 26, 7, 22, 6, 24, 18, 10, 21, 16, 13, 17, 14, 3, 9, 19, 1, 20, 4, 28, 2, 25, 8, 11, 5, 27])
-- Best Ever Fitness = 9074.146484375
解的图表与我们之前看到的最优解相同:

图 4.9:使用 2 个锦标赛大小和精英主义的程序找到的最佳解的图表
以下的统计图表表明,我们成功地消除了之前观察到的“噪声”。与原始尝试相比,我们还能够在更长的时间内保持平均值与最佳值之间的距离:

图 4.10:使用 2 个锦标赛大小和精英主义的程序统计数据
在下一节中,我们将讨论VRP,它为我们刚刚解决的问题增添了一个有趣的变化。
求解 VRP
假设你现在管理一个更大的配送中心。你仍然需要将包裹送到一份客户名单,但现在你可以使用一支多辆车组成的车队。怎样用这些车辆以最佳方式将包裹送到客户那里呢?
这是一个 VRP(车辆路径问题)的示例,它是上一节中描述的 TSP(旅行商问题)的推广。基本的 VRP 包括以下三个组件:
-
需要访问的地点列表
-
车辆数量
-
仓库的位置,用作每辆车的起点和终点
该问题有许多变种,例如多个仓库位置、时间敏感的交付、不同类型的车辆(不同的容量、不同的燃油消耗)等等。
该问题的目标是最小化成本,而成本的定义可以有许多不同的方式。例如,包括最小化交付所有包裹所需的时间、最小化燃料成本以及最小化各车辆之间的旅行时间差异。
这里展示了一个带有三辆车的 VRP 示例。城市用黑色圆圈标记,仓库位置用空白方块标记,而三辆车的路线则用三种不同的颜色标记:

图 4.11:具有三辆车的 VRP 示例
在我们的例子中,我们将以优化所有包裹的送达时间为目标。由于所有车辆同时运作,这一度量标准由执行最长路线的车辆决定。因此,我们可以将目标设定为最小化参与车辆中最长路线的长度。例如,如果我们有三辆车,每个解决方案包含三条路线,我们将评估这三条路线,然后只考虑其中最长的一条进行评分——路线越长,得分越差。这将本质上鼓励三条路线尽量变短,同时彼此的长度更接近。
得益于这两个问题的相似性,我们可以利用之前编写的代码来解决 TSP 问题,从而解决 VRP 问题。
在我们为 TSP 创建的解决方案基础上,我们可以按如下方式表示车辆路线:
-
一个 TSP 实例,即城市及其坐标的列表(或它们之间的相互距离)
-
仓库位置,选择自现有城市,并用该城市的索引表示
-
使用的车辆数量
在接下来的两个小节中,我们将向您展示如何实现这个解决方案。
解决方案表示
像往常一样,我们首先需要解决的问题是如何表示这个问题的解决方案。
为了说明我们建议的表示方法,我们将查看下图中的 10 城市示例问题,其中城市位置用从 0 到 9 的数字标记:

图 4.12:带有编号城市位置的 VRP 示例
一种创造性地表示候选 VRP 解决方案的方法,同时保持与先前解决的 TSP 相似性,是使用一个包含从 0 到 (n-1) + (m-1) 的数字的列表,其中 n 是城市的数量,m 是车辆的数量。例如,如果城市的数量是 10,车辆的数量是 3(n = 10, m = 3),我们将有一个包含从 0 到 11 的所有整数的列表,如下所示:
[0, 6, 8, 9, 11, 3, 4, 5, 7, 10, 1, 2]
前 n 个整数值,在我们的例子中是 0...9,依然代表城市,和之前一样。然而,最后的 (m - 1) 个整数值,在我们的例子中是 10 和 11,用作分隔符(或“分隔符”),将列表分成不同的路线。举个例子,[0, 6, 8, 9 11, 3, 4, 5, 7, 10, 1, 2]将被分成以下三条路线:
[0, 6, 8, 9], [3, 4, 5, 7], [1, 2]
接下来,需要移除仓库位置的索引,因为它不是特定路线的一部分。如果仓库位置的索引是 7,那么得到的路线将如下所示:
[0, 6, 8, 9], [3, 4, 5], [1, 2]
在计算每条路线所覆盖的距离时,我们需要记住,每条路线都是从仓库位置(7)出发并以该位置结束的。所以,为了计算距离并绘制路线,我们将使用以下数据:
[7, 0, 6, 8, 9, 7], [7, 3, 4, 5, 7], [7, 1, 2, 7]
这个候选解决方案在下图中有所体现:
![Figure 4.13: Visualization of the candidate solution [0, 6, 8, 9, 11, 3, 4, 5, 7, 10, 1, 2]](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-gnt-algo-py-2e/img/B20851_04_13.jpg)
图 4.13: 候选解决方案的可视化 [0, 6, 8, 9, 11, 3, 4, 5, 7, 10, 1, 2]
在下一小节中,我们将探讨这个思想的 Python 实现。
Python 问题表示
为了封装 VRP 问题,我们创建了一个名为VehicleRoutingProblem的 Python 类。此类包含在vrp.py文件中,可以在github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_04/vrp.py找到。
VehicleRoutingProblem类包含TravelingSalesmanProblem类的实例,后者用作包含城市索引及其对应位置和距离的容器。创建VehicleRoutingProblem类的实例时,内部会创建和初始化底层TravelingSalesmanProblem的实例。
使用底层TravelingSalesmanProblem的名称以及仓库位置索引和车辆数量来初始化VehicleRoutingProblem类。
另外,VehicleRoutingProblem类提供以下公共方法:
-
getRoutes(indices): 这通过检测“分隔符”索引将给定索引列表分解为单独的路线
-
getRouteDistance(indices): 这计算从仓库位置开始并经过给定索引描述的城市路径的总距离
-
getMaxDistance(indices): 这在将索引分解为单独路线后,计算给定索引描述的各路径距离中的最大距离
-
getTotalDistance(indices): 这计算由给定索引描述的各路径组合距离的总和
-
plotData(indices): 这将索引列表分解为单独的路线并以不同颜色绘制每条路线
当作为独立程序执行时,通过创建VehicleRoutingProblem类的实例并将其基础 TSP 设置为“bayg29”(与前一节中使用的相同问题)的main方法,运行这些方法。车辆数量设置为 3,仓库位置索引设置为 12(映射到一个具有中心位置的城市)。以下图显示了城市位置(红色点)和仓库(绿色“x”)的位置:

图 4.14: 基于“bayg29” TSP 的 VRP 图表。
红色点标记城市,绿色“X”标记仓库
然后,主方法生成一个随机解决方案,将其分解为路线,并计算距离,如下所示:
random solution = [27, 23, 7, 18, 30, 14, 19, 3, 16, 2, 26, 9, 24, 22, 15, 17, 28, 11, 21, 12, 8, 4, 5, 13, 25, 6, 0, 29, 10, 1, 20]
route breakdown = [[27, 23, 7, 18], [14, 19, 3, 16, 2, 26, 9, 24, 22, 15, 17, 28, 11, 21, 8, 4, 5, 13, 25, 6, 0], [10, 1, 20]]
total distance = 26653.845703125
max distance = 21517.686
请注意,原始的随机解决方案索引列表是如何使用分隔符索引(29 和 30)被分解为独立的路线的。此随机解决方案的图形如下所示:

图 4.15:VRP 三辆车的随机解决方案图
正如我们从随机解决方案中预期的那样,它远未达到最优。这一点从长(绿色)路线中城市顺序的低效性以及一条路线(绿色)比另外两条(红色和紫色)明显更长可以看出。
在接下来的小节中,我们将尝试使用遗传算法方法生成良好的解决方案。
遗传算法解决方案
我们为 VRP 创建的遗传算法解决方案位于04-solve-vrp.py Python 文件中,文件可以通过github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_04/04_solve_vrp.py访问。
由于我们能够在 TSP 的基础上进行构建,并且使用了类似的解决方案表示方法——一个索引数组——我们可以使用与前一节相同的遗传算法方法。我们还可以通过重用为遗传流创建的精英版本来利用精英主义。这使得我们的遗传算法解决方案与我们为 TSP 所用的解决方案非常相似。
以下步骤详细描述了我们解决方案的主要部分:
-
程序通过创建VehicleRoutingProblem类的一个实例开始,使用“bayg29”TSP 作为其底层数据,并将仓库位置设置为 12,车辆数量设置为 3:
TSP_NAME = "bayg29" NUM_OF_VEHICLES = 3 DEPOT_LOCATION = 12 vrp = vrp.VehicleRoutingProblem(TSP_NAME, NUM_OF_VEHICLES, DEPOT_LOCATION) -
适应度函数设置为最小化三条路线中最长路线的距离:
def vrpDistance(individual): return vrp.getMaxDistance(individual), toolbox.register("evaluate", vrpDistance) -
对于遗传操作符,我们再次使用锦标赛选择,锦标赛大小为 2,并辅以精英主义方法,以及专门针对有序列表的交叉和变异操作符:
# Genetic operators: toolbox.register("select", tools.selTournament, tournsize=2) toolbox.register("mate", tools.cxUniformPartialyMatched, \ indpb=2.0/len(vrp)) toolbox.register("mutate", tools.mutShuffleIndexes, \ indpb=1.0/len(vrp)) -
由于 VRP 本质上比 TSP 更为复杂,我们选择了比之前更大的种群规模和更高的代数:
# Genetic Algorithm constants: POPULATION_SIZE = 500 P_CROSSOVER = 0.9 P_MUTATION = 0.2 MAX_GENERATIONS = 1000 HALL_OF_FAME_SIZE = 30
就是这样!我们准备好运行程序了。我们通过这些设置得到的结果如下:三条路线,最长路线长度为3857:
-- Best Ever Individual = Individual('i', [0, 20, 17, 16, 13, 21, 10, 14, 3, 29, 15, 23, 7, 26, 12, 22, 6, 24, 18, 9, 19, 30, 27, 11, 5, 4, 8, 25, 2, 28, 1])
-- Best Ever Fitness = 3857.36376953125
-- Route Breakdown = [[0, 20, 17, 16, 13, 21, 10, 14, 3], [15, 23, 7, 26, 22, 6, 24, 18, 9, 19], [27, 11, 5, 4, 8, 25, 2, 28, 1]]
-- total distance = 11541.875
-- max distance = 3857.3638
请注意,解决方案是如何被分解为三条独立的路线的,使用最高的两个索引(29,30)作为分隔符,并忽略了仓库位置(12)。最终我们得到了三条路线,其中两条覆盖了九个城市,第三条覆盖了十个城市。
绘制解决方案时,得到以下图形,显示了三条结果路线:

图 4.16:程序为 VRP 三辆车找到的最佳解决方案图
下图展示的统计数据表明,算法在达到 300 代之前进行了大部分优化。之后,有几个小幅改进:

图 4.17:使用三辆车解决 VRP 程序的统计数据
如果改变车辆数量呢?让我们在不做其他更改的情况下,将车辆数量增加到六并重新运行算法:
NUM_OF_VEHICLES = 6
本次运行的结果如下所示——六条路线,最大长度为2803:
-- Best Ever Individual = Individual('i', [27, 11, 5, 8, 4, 33, 12, 24, 6, 22, 7, 23, 29, 28, 20, 0, 26, 15, 32, 3, 18, 13, 17, 1, 31, 19, 25, 2, 30, 9, 14, 16, 21, 10])
-- Best Ever Fitness = 2803.584716796875
-- Route Breakdown = [[27, 11, 5, 8, 4], [24, 6, 22, 7, 23], [28, 20, 0, 26, 15], [3, 18, 13, 17, 1], [19, 25, 2], [9, 14, 16, 21, 10]]
-- total distance = 16317.9892578125
-- max distance = 2803.5847
请注意,增加车辆数量两倍并没有以相似的方式减少最大距离(六辆车的2803与三辆车的3857相比)。这可能是因为每条单独的路线仍然需要在仓库位置开始和结束,这个位置也算在路线的城市之中。
绘制解决方案后,得到下图,展示了六条结果路线:

图 4.18:程序为六辆车解决 VRP 所找到的最佳解决方案图
这个图表展示的一个有趣点是,橙色路线似乎没有被优化。由于我们告诉遗传算法最小化最长路线,任何比最长路线短的路线可能不会进一步优化。鼓励你修改我们的解决方案,以进一步优化这些路线。
与三辆车的情况类似,下图展示的统计数据表明,算法在达到 200 代之前做了大部分优化,之后出现了几个小幅改进:

图 4.19:使用六辆车解决 VRP 程序的统计数据
我们找到的解决方案似乎是合理的。我们能做得更好吗?如果使用其他数量的车辆呢?或者其他仓库位置呢?不同的遗传操作符或不同的参数设置呢?甚至是不同的适应度标准?我们鼓励你尝试这些并从实验中学习。
总结
本章介绍了搜索问题和组合优化问题。然后,我们深入研究了三类经典的组合问题——它们在现实生活中有着广泛的应用——背包问题、旅行商问题(TSP)和车辆路径问题(VRP)。对于每个问题,我们都采用了类似的过程:找到一个合适的解决方案表示,创建一个封装问题并评估给定解决方案的类,然后创建一个利用该类的遗传算法解决方案。通过实验基因型到表现型的映射和精英主义支持的探索,我们为这三个问题都得到了有效的解决方案。
在下一章中,我们将研究一类密切相关的任务,即约束满足问题,从经典的n-皇后问题开始。
进一步阅读
如需更多信息,请参考以下资源:
-
使用动态规划解决背包问题,摘自 Giuseppe Ciaburro 的《Keras 强化学习项目》,2018 年 9 月
-
VRP,摘自 Giuseppe Ciaburro 的《Keras 强化学习项目》,2018 年 9 月
第五章:约束满足
在本章中,您将学习如何利用遗传算法解决约束满足问题。我们将从描述约束满足的概念开始,并讨论它如何应用于搜索问题和组合优化。然后,我们将通过几个实际示例,展示约束满足问题及其基于 Python 的解决方案,使用 DEAP 框架。我们将讨论的问题包括著名的N-皇后问题,接着是护士排班问题,最后是图着色问题。在此过程中,我们将了解硬约束和软约束之间的区别,并学习如何将它们纳入解决过程。
在本章中,我们将涵盖以下主题:
-
理解约束满足问题的性质
-
使用 DEAP 框架编写的遗传算法解决 N-皇后问题
-
使用 DEAP 框架编写的遗传算法解决护士排班问题的示例
-
使用 DEAP 框架编写的遗传算法解决图着色问题
-
理解硬约束和软约束的概念,以及在解决问题时如何应用它们
技术要求
在本章中,我们将使用 Python 3 和以下支持库:
-
deap
-
numpy
-
matplotlib
-
seaborn
-
networkx – 本章介绍
重要提示
如果您使用的是我们提供的 requirements.txt 文件(参见 第三章),这些库将在您的环境中。
本章中将使用的程序可以在本书的 GitHub 仓库中找到,链接为 github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/tree/main/chapter_05。
查看以下视频,了解代码的实际应用:packt.link/OEBOd。
搜索问题中的约束满足
在上一章中,我们研究了如何解决搜索问题,重点是有条不紊地评估状态及其状态之间的转变。每个状态转变通常涉及到成本或收益,搜索的目标是最小化成本或最大化收益。约束满足问题是搜索问题的一种变体,其中状态必须满足多个约束或限制。如果我们能将各种约束违反转化为成本,并努力最小化成本,那么解决约束满足问题就可以类似于解决一般的搜索问题。
像组合优化问题一样,约束满足问题在人工智能、运筹学和模式匹配等领域有重要应用。更好地理解这些问题有助于解决看似无关的各种问题。约束满足问题通常具有高度复杂性,这使得遗传算法成为解决它们的合适候选方法。
N 皇后问题将在下一节中介绍,展示了约束满足问题的概念,并演示了如何以与我们在上一章中研究的问题非常相似的方式来解决这些问题。
解决 N 皇后问题
最初被称为 八皇后谜题 的经典 N 皇后问题源自国际象棋游戏,8x8 棋盘是它的早期舞台。任务是将八个国际象棋皇后放置在棋盘上,确保它们之间没有任何威胁。换句话说,任何两只皇后都不能在同一行、同一列或同一对角线上。N 皇后问题类似,使用一个 N×N 的棋盘和 N 个国际象棋皇后。
已知对于任何自然数 n,除了 n=2 和 n=3 的情况外,该问题都有解。对于最初的八皇后问题,有 92 种解,或者如果将对称解视为相同,则有 12 种唯一解。以下是其中一种解:

图 5.1:八皇后谜题的 92 种可能解之一
通过应用组合数学,计算在 8×8 棋盘上放置八个棋子的所有可能方式,得到 4,426,165,368 种组合。然而,如果我们能以确保没有两只皇后被放置在同一行或同一列的方式来生成候选解,则可能的组合数量会大大减少,变为 8!(8 的阶乘),即 40,320。我们将在下一小节中利用这一思想来选择我们解决此问题的表示方式。
解的表示
在解决 N 皇后问题时,我们可以利用每一行都会恰好放置一只皇后,且没有两只皇后会在同一列的知识。这意味着我们可以将任何候选解表示为一个有序整数列表——或者一个索引列表,每个索引表示当前行中皇后所在的列。
例如,在一个 4×4 的棋盘上解决四后问题时,我们有以下索引列表:
[3, 2, 0, 1]
这转换为以下位置:
-
在第一行,皇后被放置在位置 3(第四列)。
-
在第二行,皇后被放置在位置 2(第三列)。
-
在第三行,皇后被放置在位置 0(第一列)。
-
在第四行,皇后被放置在位置 1(第二列)。
如下图所示:
![图 5.2:由列表[3, 2, 0, 1]表示的皇后排列示意图](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-gnt-algo-py-2e/img/B20851_05_02.jpg)
图 5.2:由列表[3, 2, 0, 1]表示的皇后排列示意图
同样,索引的另一种排列可能如下所示:
[1, 3, 0, 2]
该排列表示以下图示中的候选解:
![图 5.3:由列表[1, 3, 0, 2]表示的皇后排列示意图](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-gnt-algo-py-2e/img/B20851_05_03.jpg)
图 5.3:由列表[1, 3, 0, 2]表示的皇后排列示意图
以这种方式表示的候选解中唯一可能的约束冲突是皇后对之间共享的对角线。
例如,我们讨论的第一个候选解包含两个违反约束条件的情况,如下所示:
![图 5.4:由列表[3, 2, 0, 1]表示的皇后排列示意图,标明了约束条件冲突](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-gnt-algo-py-2e/img/B20851_05_04.jpg)
图 5.4:由列表[3, 2, 0, 1]表示的皇后排列示意图,标明了约束条件冲突
然而,前面的排列没有违反任何约束条件。
这意味着,在评估以这种方式表示的解时,我们只需要找到并计算它们所代表位置之间共享的对角线。
我们刚刚讨论的解表示方法是 Python 类的核心部分,我们将在下一小节中描述该类。
Python 问题表示
为了封装 N 皇后问题,我们创建了一个名为NQueensProblem的 Python 类。该类可以在本书 GitHub 仓库中的queens.py文件找到:github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_05/queens.py。
该类以问题的期望大小进行初始化,并提供以下公共方法:
-
getViolationsCount(positions):此函数计算给定解中违反约束条件的数量,该解由索引列表表示,如前一小节所述
-
plotBoard(positions):此函数根据给定的解绘制皇后在棋盘上的位置
该类的main方法通过创建一个八皇后问题并测试以下候选解来运用该类方法:
[1, 2, 7, 5, 0, 3, 4, 6]
随后绘制候选解并计算违反约束条件的数量。
结果输出如下:
Number of violations = 3
下面是该问题的图示——你能找出所有三个违反约束条件的情况吗?
![图 5.5:由列表[1, 2, 7, 5, 0, 3, 4, 6]表示的八皇后排列示意图](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-gnt-algo-py-2e/img/B20851_05_05.jpg)
图 5.5:由列表[1, 2, 7, 5, 0, 3, 4, 6]表示的八皇后排列示意图
在下一小节中,我们将应用遗传算法方法来解决 N 皇后问题。
遗传算法解法
为了使用遗传算法解决 N 皇后问题,我们创建了一个名为01-solve-n-queens.py的 Python 程序,该程序位于github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_05/01_solve_n_queens.py。
由于我们为此问题选择的解决方案表示是一个索引的列表(或数组),类似于我们在第四章中为旅行商问题(TSP)和车辆路径规划问题(VRP)使用的表示,我们可以利用类似的遗传方法,正如我们在那里使用的那样。此外,我们将再次利用精英主义,通过重用我们为 DEAP 的简单遗传流程创建的精英版本。
以下步骤描述了我们解决方案的主要部分:
-
我们的程序通过使用我们希望解决的问题的大小,创建NQueensProblem类的实例来开始:
nQueens = queens.NQueensProblem(NUM_OF_QUEENS) -
由于我们的目标是最小化冲突数量(希望达到 0),我们定义了一个单一目标,即最小化适应度策略:
creator.create("FitnessMin", base.Fitness, weights=(-1.0,)) -
由于解决方案由一个有序的整数列表表示,其中每个整数表示一个皇后的列位置,我们可以使用以下工具箱定义来创建初始种群:
# create an operator that generates randomly shuffled indices: toolbox.register("randomOrder", random.sample, \ range(len(nQueens)), len(nQueens)) toolbox.register("individualCreator", tools.initIterate, \ creator.Individual, toolbox.randomOrder) toolbox.register("populationCreator", tools.initRepeat, \ list, toolbox.individualCreator) -
实际的适应度函数设置为计算由于皇后在棋盘上的放置所引起的冲突数量,每个解决方案代表一个冲突:
def getViolationsCount(individual): return nQueens.getViolationsCount(individual), toolbox.register("evaluate", getViolationsCount) -
至于遗传操作符,我们使用tournament selection(锦标赛选择),其锦标赛大小为2,以及交叉和突变操作符,这些操作符是针对有序列表的:
# Genetic operators: toolbox.register("select", tools.selTournament, \ tournsize=2) toolbox.register("mate", tools.cxUniformPartialyMatched, \ indpb=2.0/len(nQueens)) toolbox.register("mutate", tools.mutShuffleIndexes, \ indpb=1.0/len(nQueens)) -
此外,我们继续使用精英主义方法,其中名人堂(HOF)成员——当前最好的个体——始终不受影响地传递到下一代。正如我们在上一章中发现的那样,这种方法与大小为 2 的锦标赛选择非常匹配:
population, logbook = elitism.eaSimpleWithElitism(population, toolbox, cxpb=P_CROSSOVER, mutpb=P_MUTATION, ngen=MAX_GENERATIONS, stats=stats, halloffame=hof, verbose=True) -
由于每个 N 皇后问题可以有多个可能的解决方案,我们打印出所有 HOF 成员,而不仅仅是顶部的一个,以便我们可以看到找到多少个有效解决方案:
print("- Best solutions are:") for i in range(HALL_OF_FAME_SIZE): print(i, ": ", hof.items[i].fitness.values[0], " -> ", hof.items[i])
正如我们之前看到的那样,我们的解决方案表示将八皇后情况简化为大约 40,000 个可能的组合,这使得问题相对较小。为了增加趣味性,让我们将大小增加到 16 个皇后,其中可能的候选解决方案数量将是16!。这个值计算为一个巨大的数字:20,922,789,888,000。这个问题的有效解决方案数量也相当大,接近 1500 万个。然而,与可能的组合数相比,寻找有效解决方案仍然像是在大海捞针。
在运行程序之前,让我们设置算法常量,如下所示:
NUM_OF_QUEENS = 16
POPULATION_SIZE = 300
MAX_GENERATIONS = 100
HALL_OF_FAME_SIZE = 30
P_CROSSOVER = 0.9
P_MUTATION = 0.1
使用这些设置运行程序将得到以下输出:
gen nevals min avg
0 300 3 10.4533
1 246 3 8.85333
..
23 250 1 4.38
24 227 0 4.32
..
- Best solutions are:
0 : 0.0 -> Individual('i', [7, 2, 8, 14, 9, 4, 0, 15, 6, 11, 13, 1, 3, 5, 10, 12])
1 : 0.0 -> Individual('i', [7, 2, 6, 14, 9, 4, 0, 15, 8, 11, 13, 1, 3, 5, 12, 10])
..
7 : 0.0 -> Individual('i', [14, 2, 6, 12, 7, 4, 0, 15, 8, 11, 3, 1, 9, 5, 10, 13])
8 : 1.0 -> Individual('i', [2, 13, 6, 12, 7, 4, 0, 15, 8, 14, 3, 1, 9, 5, 10, 11])
..
从打印输出中,我们可以看到第一个解在第 24 代找到,其中适应度值显示为0,这意味着没有违反规则。此外,最佳解的打印输出表明在运行过程中找到的八个不同解,这些解的编号是0到7,它们的适应度值都是0。下一个解的适应度值已经为1,表示存在违规。
程序生成的第一个图表展示了根据找到的第一个有效解所定义的16x16国际象棋棋盘上 16 个皇后的位置,[7, 2, 8, 14, 9, 4, 0, 15, 6, 11, 13, 1, 3, 5, 10, 12]:

图 5.6:程序找到的有效 16 皇后排列的图示
第二个图表包含了随着代数增加,最大和平均适应度值的图表。从该图表中,我们可以看到,尽管最佳适应度值为零在早期就找到——大约在第 24 代——但随着更多解的出现,平均适应度值不断下降:

图 5.7:程序解决 16 皇后问题的统计数据
将MAX_GENERATIONS的值增加到 400 而不做其他任何更改,将会找到 38 个有效解。如果我们将MAX_GENERATIONS增加到 500,HOF 中的所有 50 个成员将包含有效解。鼓励你尝试基因算法设置的各种组合,同时也可以解决其他规模的 N 皇后问题。
在下一部分,我们将从安排棋盘上的棋子过渡到为工作安排排班。
解决护士排班问题
假设你负责为医院科室安排本周的护士班次。一天有三个班次——早班、午班和晚班——每个班次你需要安排一个或多个在科室工作的八名护士。如果这听起来像是一个简单的任务,那你可以看看以下相关的医院规定:
-
护士不能连续工作两个班次
-
护士每周不得工作超过五个班次
-
科室中每个班次的护士数量应在以下限制范围内:
-
早班:2-3 名护士
-
午班:2-4 名护士
-
晚班:1-2 名护士
-
此外,每个护士可能有班次偏好。例如,一名护士只愿意上早班,另一名护士不愿意上下午班,等等。
这个任务是护士排班问题(NSP)的一个示例,它可以有许多变种。可能的变种包括不同护士的不同专科、加班班次(超时工作)的安排,甚至是不同类型的班次——例如 8 小时班次和 12 小时班次。
到现在为止,可能已经觉得编写一个程序来为你排班是个不错的主意。为什么不运用我们对遗传算法的了解来实现这样一个程序呢?和往常一样,我们将从表示问题的解法开始。
解法表示
为了解决护士排班问题,我们决定使用二进制列表(或数组)来表示排班,因为这样直观易懂,而且我们已经看到遗传算法可以自然地处理这种表示。
对于每个护士,我们可以有一个二进制字符串,表示该周的 21 个班次。值为 1 表示该护士被安排工作的班次。例如,看看以下的二进制列表:
[0, 1, 0, 1, 0, 1, 0, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 1, 0]
这个列表可以被拆分为以下三个值一组,表示该护士每周每天将要工作的班次:
| 周日 | 周一 | 周二 | 周三 | 周四 | 周五 | 周六 |
|---|---|---|---|---|---|---|
| [0, 1, 0] | [1, 0, 1] | [0, 1, 1] | [0, 0, 0] | [0, 0, 1] | [1, 0, 0] | [0, 1, 0] |
| 下午 | 早晚班 | 下午晚班 | 无 | 夜班 | 早班 | 下午 |
表 5.1:将二进制序列转换为每日班次
所有护士的排班可以被连接在一起,形成一个长的二进制列表,表示整个解法。
在评估解法时,这个长列表可以被拆分成各个护士的排班,并检查是否违反约束条件。例如,前面提供的护士排班中,包含了两次连续的 1 值,表示连续的班次(下午接夜班,夜班接早班)。该护士的每周班次数可以通过将列表中的二进制值相加来计算,结果是 8 个班次。我们还可以通过检查每一天的班次与该护士的偏好班次来轻松验证是否遵循了班次偏好。
最后,为了检查每个班次护士数量的约束条件,我们可以将所有护士的周排班加总,并查找那些超出最大允许值或低于最小允许值的条目。
但在继续实施之前,我们需要讨论硬约束与软约束之间的区别。
硬约束与软约束
在解决护士排班问题时,我们应该牢记,某些约束条件代表了医院规则,是无法违反的。包含一个或多个违反这些规则的排班将被视为无效。更一般地来说,这些被称为硬约束。
另一方面,护士的偏好可以视为软约束。我们希望尽可能遵守这些偏好,包含没有违规或较少违规这些约束的解被认为优于包含更多违规的解。然而,违反这些约束并不会使解无效。
对于 N 皇后问题,所有的约束——行、列和对角线——都是硬约束。如果我们没有找到一个违规数为零的解,那么我们就没有有效的解。而在这里,我们寻求一个既不会违反任何医院规则,又能最小化护士偏好违规次数的解。
处理软约束与我们在任何优化问题中所做的类似——也就是说,我们努力将它们最小化——那么,我们该如何处理伴随其产生的硬约束呢?有几种可能的策略:
-
找到一种特定的解的表示(编码),消除硬约束违规的可能性。在解决 N 皇后问题时,我们能够以一种消除两个约束(行和列)违规可能性的方式表示解,这大大简化了我们的解法。但一般来说,这种编码可能很难找到。
-
在评估解时,丢弃违反任何硬约束的候选解。这种方法的缺点是丢失这些解中包含的信息,而这些信息对问题可能是有价值的。这可能会显著减慢优化过程。
-
在评估解时,修复违反任何硬约束的候选解。换句话说,找到一种方法来操作解并修改它,使其不再违反约束条件。为大多数问题创建这样的修复过程可能会很困难甚至不可能,同时,修复过程可能会导致大量信息丢失。
-
在评估解时,惩罚违反任何硬约束的候选解。这将降低解的得分,使其不那么理想,但不会完全排除该解,因此其中包含的信息没有丢失。实际上,这使得硬约束被视为类似于软约束,但惩罚更重。使用这种方法时,挑战可能是找到适当的惩罚幅度。惩罚过重可能会导致这些解实际上被排除,而惩罚过轻则可能使这些解看起来像是最优解。
在我们的案例中,我们选择应用第四种方法,并对硬约束的违规行为施加比软约束更大的惩罚。我们通过创建一个成本函数来实现这一点,其中硬约束违规的成本大于软约束违规的成本。然后,使用总成本作为要最小化的适应度函数。这是在接下来的子节中将讨论的“问题表示”中实现的。
Python 问题表示
为了封装我们在本节开始时描述的护士排班问题,我们创建了一个名为NurseSchedulingProblem的 Python 类。该类位于nurses.py文件中,可以在github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_05/nurses.py找到。
该类构造函数接受hardConstraintPenalty参数,该参数表示硬约束违规的惩罚因子(而软约束违规的惩罚固定为 1)。然后,它继续初始化描述排班问题的各种参数:
# list of nurses:
self.nurses = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
# nurses' respective shift preferences - morning, evening, night:
self.shiftPreference = [[1, 0, 0], [1, 1, 0], [0, 1, 1], [0, 1, 0],
[0, 0, 1], [1, 1, 1], [0, 1, 1], [1, 1, 1]]
# min and max number of nurses allowed for each shift - morning, evening, night:
self.shiftMin = [2, 2, 1]
self.shiftMax = [3, 4, 2]
# max shifts per week allowed for each nurse:
self.maxShiftsPerWeek = 5
该类使用以下方法将给定的排班转换为每个护士的单独排班字典:
- getNurseShifts(schedule)
以下方法用于计算各种类型的违规行为:
-
countConsecutiveShiftViolations(nurseShiftsDict)
-
countShiftsPerWeekViolations(nurseShiftsDict)
-
countNursesPerShiftViolations(nurseShiftsDict)
-
countShiftPreferenceViolations(nurseShiftsDict)
此外,该类还提供了以下公共方法:
-
getCost(schedule):计算给定排班中各种违规行为的总成本。该方法使用hardConstraintPenalty变量的值。
-
printScheduleInfo(schedule):打印排班和违规详情。
该类的主要方法通过创建护士排班问题的实例并测试随机生成的解决方案来执行该类的方法。生成的输出可能如下所示,其中hardConstraintPenalty的值设置为10:
Random Solution =
[0 1 0 0 0 1 0 0 0 1 0 0 0 0 1 0 1 1 1 0 1 0 1 1 1 1 1 1 1 1 0 0 1 1 1 0 1 0 0 0 0 0 1 1 1 1 1 0 1 1 0 1 0 1 0 1 1 0 0 0 0 0 0 0 0 1 1 0 1 1 1 1 0 1 0 1 1 1 0 1 0 1 0 1 0 0 1 0 1 1 1 1 1 1 1 1 1 1 1 0 0 1 1 1 1 1 1 1 1 0 1 0 1 1 0 1 0 1 1 0 1 0 1 0 0 1 1 0 1 1 1 0 0 0 0 0 0 0 0 0 1 0 1 1 1 0 0 0 0 1 0 0 0 0 0 1 0 1 0 1 0 0 1 1 1 0 1 0]
Schedule for each nurse:
A : [0 1 0 0 0 1 0 0 0 1 0 0 0 0 1 0 1 1 1 0 1]
B : [0 1 1 1 1 1 1 1 1 0 0 1 1 1 0 1 0 0 0 0 0]
C : [1 1 1 1 1 0 1 1 0 1 0 1 0 1 1 0 0 0 0 0 0]
D : [0 0 1 1 0 1 1 1 1 0 1 0 1 1 1 0 1 0 1 0 1]
E : [0 0 1 0 1 1 1 1 1 1 1 1 1 1 1 0 0 1 1 1 1]
F : [1 1 1 1 0 1 0 1 1 0 1 0 1 1 0 1 0 1 0 0 1]
G : [1 0 1 1 1 0 0 0 0 0 0 0 0 0 1 0 1 1 1 0 0]
H : [0 0 1 0 0 0 0 0 1 0 1 0 1 0 0 1 1 1 0 1 0]
consecutive shift violations = 47
weekly Shifts = [8, 12, 11, 13, 16, 13, 8, 8]
Shifts Per Week Violations = 49
Nurses Per Shift = [3, 4, 7, 5, 4, 5, 4, 5, 5, 3, 4, 3, 5, 5, 5, 3, 4, 5, 4, 2, 4]
Nurses Per Shift Violations = 28
Shift Preference Violations = 39
Total Cost = 1279
从这些结果可以明显看出,随机生成的解决方案很可能会导致大量违规行为,从而产生较大的成本值。在下一个子节中,我们将尝试通过基于遗传算法的解决方案来最小化成本并消除所有硬约束违规。
遗传算法解决方案
为了使用遗传算法解决护士排班问题,我们创建了一个名为02-solve-nurses.py的 Python 程序,该程序位于github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_05/02_solve_nurses.py。
由于我们为这个问题选择的解表示方法是一个二进制值的列表(或数组),我们能够使用我们已经用于解决多个问题的相同遗传算法方法,例如我们在第四章中描述的 0-1 背包问题,组合优化。
我们解决方案的主要部分在以下步骤中描述:
-
我们的程序首先创建一个NurseSchedulingProblem类的实例,并为hardConstraintPenalty设置期望值,该值由HARD_CONSTRAINT_PENALTY常量设置:
nsp = nurses.NurseSchedulingProblem(HARD_CONSTRAINT_PENALTY) -
由于我们的目标是最小化成本,我们必须定义一个单一目标,即最小化适应度策略:
creator.create("FitnessMin", base.Fitness, weights=(-1.0,)) -
由于解表示为 0 或 1 值的列表,我们必须使用以下工具箱定义来创建初始种群:
creator.create("Individual", list, fitness=creator.FitnessMin) toolbox.register("zeroOrOne", random.randint, 0, 1) toolbox.register("individualCreator", tools.initRepeat, \ creator.Individual, toolbox.zeroOrOne, len(nsp)) toolbox.register("populationCreator", tools.initRepeat, \ list, toolbox.individualCreator) -
实际的适应度函数设置为计算排班中各种违规的成本,由每个解决方案表示:
def getCost(individual): return nsp.getCost(individual), toolbox.register("evaluate", getCost) -
至于遗传算子,我们必须使用2的锦标赛选择,并结合两点交叉和翻转位变异,因为这适用于二进制列表:
toolbox.register("select", tools.selTournament, tournsize=2) toolbox.register("mate", tools.cxTwoPoint) toolbox.register("mutate", tools.mutFlipBit, indpb=1.0/len(nsp)) -
我们继续使用精英主义方法,其中 HOF 成员——当前最优秀的个体——始终被无修改地传递到下一代:
population, logbook = elitism.eaSimpleWithElitism( population, toolbox, cxpb=P_CROSSOVER, \ mutpb=P_MUTATION, ngen=MAX_GENERATIONS, \ stats=stats, halloffame=hof, verbose=True) -
当算法完成时,我们打印出找到的最佳解决方案的详细信息:
nsp.printScheduleInfo(best)
在运行程序之前,我们设置算法常量,如下所示:
POPULATION_SIZE = 300
P_CROSSOVER = 0.9
P_MUTATION = 0.1
MAX_GENERATIONS = 200
HALL_OF_FAME_SIZE = 30
此外,让我们从将违反硬约束的惩罚设置为1开始,这使得违反硬约束的成本与违反软约束的成本相似:
HARD_CONSTRAINT_PENALTY = 1
使用这些设置运行程序会产生以下输出:
-- Best Fitness = 3.0
-- Schedule =
Schedule for each nurse:
A : [0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0]
B : [1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0]
C : [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0]
D : [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0]
E : [0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0]
F : [0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0]
G : [0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1]
H : [1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0]
consecutive shift violations = 0
weekly Shifts = [5, 6, 2, 5, 4, 5, 5, 5]
Shifts Per Week Violations = 1
Nurses Per Shift = [2, 2, 1, 2, 2, 1, 2, 2, 1, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 1]
Nurses Per Shift Violations = 0
Shift Preference Violations = 2
这看起来是一个不错的结果,因为我们最终只出现了三次约束违规。然而,其中一个是每周轮班违规—护士 B 被安排了六个班次,超过了每周最多五个班次的限制。这足以使整个解决方案不可接受。
为了消除这种类型的违规,我们将继续将硬约束惩罚值增加到10:
HARD_CONSTRAINT_PENALTY = 10
现在,结果如下:
-- Best Fitness = 3.0
-- Schedule =
Schedule for each nurse:
A : [0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0]
B : [1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0]
C : [0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1]
D : [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0]
E : [0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0]
F : [0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0]
G : [0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0]
H : [1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0]
consecutive shift violations = 0
weekly Shifts = [4, 5, 5, 5, 3, 5, 5, 5]
Shifts Per Week Violations = 0
Nurses Per Shift = [2, 2, 1, 2, 2, 1, 2, 2, 2, 2, 2, 1, 2, 2, 1, 2, 2, 2, 2, 2, 1]
Nurses Per Shift Violations = 0
Shift Preference Violations = 3
再次,我们得到了三次违规,但这次它们都是软约束违规,这使得这个解决方案有效。
下图显示了代际间的最小和平均适应度,表明在前 40-50 代中,算法成功消除了所有硬约束违规,从那时起只有少量的增量改进,发生在每次消除一个软约束时:

图 5.8:解决护士排班问题的程序统计数据
看起来,在我们的情况下,仅对硬约束违规设置 10 倍的惩罚就足够了。在其他问题中,可能需要更高的值。建议你通过改变问题的定义以及遗传算法的设置进行实验。
我们刚才看到的软约束和硬约束之间的权衡将在我们接下来的任务——图着色问题中发挥作用。
求解图着色问题
在图论的数学分支中,图是一个结构化的对象集合,表示这些对象对之间的关系。图中的对象表现为顶点(或节点),而对象对之间的关系则通过边表示。常见的图形表示方式是将顶点画为圆圈,边画为连接线,如下图所示,这是一幅彼得森图,以丹麦数学家尤利乌斯·彼得森的名字命名:

图 5.9:彼得森图
来源:commons.wikimedia.org/wiki/File:Petersen1_tiny.svg
图片来自 Leshabirukov。
图是非常有用的对象,因为它们可以表示并帮助我们研究各种现实生活中的结构、模式和关系,例如社交网络、电网布局、网站结构、语言构成、计算机网络、原子结构、迁徙模式等。
图着色任务是为图中的每个节点分配颜色,确保任何一对连接(相邻)节点不会共享相同的颜色。这也被称为图的正确着色。
下图显示了相同的彼得森图,但这次进行了正确的着色:

图 5.10:彼得森图的正确着色
来源:en.wikipedia.org/wiki/File:Petersen_graph_3-coloring.svg
着色分配通常伴随着优化要求——使用最少的颜色。例如,彼得森图可以使用三种颜色进行正确着色,如前面的图示所示。但如果只使用两种颜色,正确着色是不可能的。从图论的角度来看,这意味着该图的色数为三。
为什么我们要关心图的节点着色?许多现实生活中的问题可以通过图的表示来转换,其中图的着色代表一个解决方案——例如,为学生安排课程或为员工安排班次都可以转换为一个图,其中相邻的节点代表存在冲突的课程或班次。这样的冲突可能是同一时间段的课程,或是连续的班次(是不是很熟悉?)。由于这种冲突,将同一个人分配到两门课程(或两班次)中会使得时间表无效。如果每种颜色代表不同的人,那么将不同的颜色分配给相邻节点就能解决冲突。本章开始时遇到的 N 皇后问题可以表示为一个图着色问题,其中图中的每个节点代表棋盘上的一个方格,每一对共享同一行、列或对角线的节点通过一条边相连。其他相关的例子还包括为广播电台分配频率、电网冗余规划、交通信号灯时序,甚至是数独解题。
希望这已经说服你,图着色是一个值得解决的问题。像往常一样,我们将从制定一个合适的可能解法表示开始。
解法表示
在常用的二进制列表(或数组)表示法的基础上,我们可以使用整数列表,每个整数代表一种独特的颜色,而列表中的每个元素对应图中的一个节点。
例如,由于彼得森图有 10 个节点,我们可以为每个节点分配一个从 0 到 9 之间的索引。然后,我们可以使用一个包含 10 个元素的列表来表示该图的节点着色。
例如,让我们看看在这个特定的表示法中我们得到了什么:
[0, 2, 1, 3, 1, 2, 0, 3, 3, 0]
让我们详细讨论一下这里的内容:
-
使用了四种颜色,分别由整数0、1、2和3表示
-
图的第一个、第七个和第十个节点被涂上了第一种颜色(0)
-
第三和第五个节点被涂上了第二种颜色(1)
-
第二和第六个节点被涂上了第三种颜色(2)
-
第四、第八和第九个节点被涂上了第四种颜色(3)
为了评估解法,我们需要遍历每一对相邻节点,检查它们是否有相同的颜色。如果有相同的颜色,就发生了着色冲突,我们需要尽量减少冲突的数量,直到为零,从而实现图的正确着色。
然而,你可能记得我们还希望最小化使用的颜色数。如果我们已经知道这个数字,我们可以直接使用与已知颜色数相等的整数值。但如果我们不知道呢?一种方法是从一个估计值(或只是猜测)开始。如果我们使用这个数字找到一个合适的解决方案,我们可以减少这个数字并重试。如果没有找到解决方案,我们可以增加数字并继续尝试,直到找到最小的数字。虽然如此,通过使用软约束和硬约束,我们可能能更快地找到这个数字,正如下一小节中所描述的。
使用硬约束和软约束解决图着色问题
在本章早些时候解决护士排班问题时,我们注意到硬约束——即我们必须遵守的约束才能使解决方案有效——和软约束——即我们尽力最小化的约束,以便获得最佳解决方案。图着色问题中的颜色分配要求——即相邻的两个节点不能具有相同的颜色——是一个硬约束。为了实现有效的解决方案,我们必须将此约束的违背次数最小化为零。
然而,最小化使用的颜色数可以作为软约束引入。我们希望最小化这个数字,但不能以违反硬约束为代价。
这将允许我们使用比估计值更多的颜色启动算法,并让算法最小化直到——理想情况下——达到实际的最小颜色数。
就像我们在护士排班问题中做的那样,我们将通过创建一个成本函数来实现这个方法,其中硬约束违背的成本大于使用更多颜色所带来的成本。总成本将作为目标函数进行最小化。此功能可以集成到 Python 类中,并将在下一小节中描述。
Python 问题表示
为了封装图着色问题,我们创建了一个名为GraphColoringProblem的 Python 类。此类可以在graphs.py文件中找到,文件链接为github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_05/graphs.py。
为了实现这个类,我们将利用开源的 Python 包graph类。我们可以利用这个库中已有的许多图形,而不是从头创建图形,比如我们之前看到的佩特森图。
GraphColoringProblem类的构造函数接受作为参数的要着色的图。此外,它还接受hardConstraintPenalty参数,表示硬约束违背的惩罚因子。
构造函数接着创建了一个图的节点列表以及一个邻接矩阵,这使我们可以快速判断图中任意两个节点是否相邻:
self.nodeList = list(self.graph.nodes)
self.adjMatrix = nx.adjacency_matrix(graph).todense()
该类使用以下方法计算给定颜色排列中的着色违规数量:
- getViolationsCount****(colorArrangement)
以下方法用于计算给定颜色排列所使用的颜色数量:
- getNumberOfColors****(colorArrangement)
此外,该类提供了以下公共方法:
-
getCost****(colorArrangement):计算给定颜色排列的总成本
-
plotGraph****(colorArrangement):根据给定的颜色排列绘制图形,节点按给定颜色排列着色
该类的主要方法通过创建一个彼得森图实例并测试一个随机生成的颜色排列来执行类的方法,该排列最多包含五种颜色。此外,它将hardConstraintPenalty的值设置为10:
gcp = GraphColoringProblem(nx.petersen_graph(), 10)
solution = np.random.randint(5, size=len(gcp))
结果输出可能如下所示:
solution = [2 4 1 3 0 0 2 2 0 3]
number of colors = 5
Number of violations = 1
Cost = 15
由于这个特定的随机解决方案使用了五种颜色并导致了一个着色违规,因此计算出的成本为 15。
该解决方案的图示如下——你能找到唯一的着色违规吗?

图 5.11:五种颜色错误着色的彼得森图
在下一个小节中,我们将应用基于遗传算法的解决方案,尝试消除任何着色违规,同时最小化使用的颜色数量。
遗传算法解决方案
为了解决图着色问题,我们创建了一个名为03-solve-graphs.py的 Python 程序,程序位于github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_05/03_solve_graphs.py。
由于我们为这个问题选择的解决方案表示方式是一个整数列表,我们需要扩展遗传算法方法,改为使用二进制列表。
以下步骤描述了我们解决方案的主要步骤:
-
程序通过创建一个GraphColoringProblem类的实例开始,使用要解决的NetworkX图(在此案例中为熟悉的彼得森图)和所需的hardConstraintPenalty值,该值由HARD_CONSTRAINT_PENALTY常量设置:
gcp = graphs.GraphColoringProblem(nx.petersen_graph(), HARD_CONSTRAINT_PENALTY) -
由于我们的目标是最小化成本,我们将定义一个单一目标,即最小化适应度策略:
creator.create("FitnessMin", base.Fitness, weights=(-1.0,)) -
由于解决方案是由表示参与颜色的整数值列表表示的,我们需要定义一个随机生成器,它会生成一个介于 0 和颜色数减 1 之间的整数。这个随机整数表示参与颜色之一。然后,我们必须定义一个解决方案(个体)创建器,生成一个与给定图长度匹配的这些随机整数的列表——这就是我们如何为图中的每个节点随机分配颜色。最后,我们必须定义一个操作符,创建一个完整的个体种群:
toolbox.register("Integers", random.randint, 0, MAX_COLORS - 1) toolbox.register("individualCreator", tools.initRepeat, \ creator.Individual, toolbox.Integers, len(gcp)) toolbox.register("populationCreator", tools.initRepeat, \ list, toolbox.individualCreator) -
适应度评估函数被设置为通过调用GraphColoringProblem类的getCost()方法,计算与每个解决方案相关的着色违规成本和使用颜色数量的组合:
def getCost(individual): return gcp.getCost(individual), toolbox.register("evaluate", getCost) -
至于遗传操作符,我们仍然可以使用我们为二进制列表使用的相同选择和交叉操作;然而,变异操作需要改变。用于二进制列表的翻转位变异会在 0 和 1 之间翻转,而在这里,我们需要将给定的整数变换为另一个——随机生成的——整数,且该整数在允许的范围内。mutUniformInt操作符正是执行这个操作——我们只需像前面的整数操作符一样设置范围:
toolbox.register("select", tools.selTournament, tournsize=2) toolbox.register("mate", tools.cxTwoPoint) toolbox.register("mutate", tools.mutUniformInt, low=0, \ up=MAX_COLORS - 1, indpb=1.0/len(gcp)) -
我们继续使用精英策略,即 HOF 成员——当前最优秀的个体——总是无任何改动地传递到下一代:
population, logbook = elitism.eaSimpleWithElitism(\ population, toolbox, cxpb=P_CROSSOVER, \ mutpb=P_MUTATION, ngen=MAX_GENERATIONS, \ stats=stats, halloffame=hof, verbose=True) -
当算法结束时,我们会打印出找到的最佳解决方案的详细信息,然后再绘制图表。
gcp.plotGraph(best)
在运行程序之前,让我们设置算法常量,如下所示:
POPULATION_SIZE = 100
P_CROSSOVER = 0.9
P_MUTATION = 0.1
MAX_GENERATIONS = 100
HALL_OF_FAME_SIZE = 5
此外,我们需要将违反硬约束的惩罚设置为10,并将颜色数量设置为10:
HARD_CONSTRAINT_PENALTY = 10
MAX_COLORS = 10
使用这些设置运行程序会输出以下结果:
-- Best Individual = [5, 0, 6, 5, 0, 6, 5, 0, 0, 6]
-- Best Fitness = 3.0
Number of colors = 3
Number of violations = 0
Cost = 3
这意味着算法能够使用三种颜色为图着色,分别由整数0、5和6表示。如前所述,实际的整数值并不重要——重要的是它们之间的区分。三是已知的 Petersen 图的色数。
前面的代码生成了以下图表,说明了解决方案的有效性:

图 5.12:由程序使用三种颜色正确着色的 Petersen 图的绘图
下图展示了每代的最小和平均适应度,表明算法很快就达到了解决方案,因为 Petersen 图相对较小:

图 5.13:程序解决 Petersen 图着色问题的统计数据
为了尝试更大的图形,我们将彼得森图替换为一个阶数为5的梅西尔斯基图。该图包含 23 个节点和 71 条边,并且已知其染色数为 5:
gcp = graphs.GraphColoringProblem(nx.mycielski_graph(5),
HARD_CONSTRAINT_PENALTY)
使用与之前相同的参数,包括设置为 10 种颜色,我们得到了以下结果:
-- Best Individual = [9, 6, 9, 4, 0, 0, 6, 5, 4, 5, 1, 5, 1, 1, 6, 6, 9, 5, 9, 6, 5, 1, 4]
-- Best Fitness = 6.0
Number of colors = 6
Number of violations = 0
Cost = 6
由于我们知道该图的染色数为 5,虽然这接近最优解,但这并不是最优解。我们该如何获得最优解呢?如果我们事先不知道染色数该怎么办?一种方法是改变遗传算法的参数——例如,我们可以增加种群规模(并可能增加 HOF 大小)和/或增加代数。另一种方法是重新开始相同的搜索,但减少颜色数量。由于算法找到了六色解决方案,我们将最大颜色数减少到5,看看算法是否仍然能够找到有效解:
MAX_COLORS = 5
为什么算法现在能够找到一个五色解决方案,而在第一次时没有找到?随着我们将颜色数量从 10 减少到 5,搜索空间大大缩小——在这种情况下,从 10²³ 到 5²³(因为图中有 23 个节点)——即使在短时间运行和有限的种群规模下,算法找到最优解的机会也大大增加。因此,虽然算法的第一次运行可能已经接近解决方案,但将颜色数量不断减少,直到算法无法找到更好的解决方案,可能是一个好的做法。
在我们的例子中,当从五种颜色开始时,算法能够相对容易地找到一个五色解决方案:
-- Best Individual = [0, 3, 0, 2, 4, 4, 2, 2, 2, 4, 1, 4, 3, 1, 3, 3, 4, 4, 2, 2, 4, 3, 0]
-- Best Fitness = 5.0
Number of colors = 5
Number of violations = 0
Cost = 5
着色图的图形如下所示:

图 5.14:程序用五种颜色正确着色的梅西尔斯基图
现在,如果我们尝试将最大颜色数减少到四种,我们将始终得到至少一个违反条件的情况。
鼓励你尝试其他图形,并实验算法的各种设置。
概述
在本章中,我们介绍了约束满足问题,它们是之前研究的组合优化问题的亲戚。然后,我们探讨了三种经典的约束满足问题——N 皇后问题、护士排班问题和图着色问题。对于每个问题,我们遵循了现在熟悉的过程:为解决方案找到合适的表示,创建一个封装该问题并评估给定解决方案的类,并创建一个利用该类的遗传算法解决方案。最终,我们得到了这些问题的有效解决方案,同时了解了硬约束与软约束的概念。
到目前为止,我们一直在研究由状态和状态转移组成的离散搜索问题。在下一章,我们将研究连续空间中的搜索问题,以展示遗传算法方法的多功能性。
深入阅读
如需了解本章涉及的更多主题,请参考以下资源:
-
约束满足问题,摘自 Prateek Joshi 的书籍《Python 人工智能》,2017 年 1 月
-
图论入门,摘自 Alberto Boschetti 和 Luca Massaron 的书籍《Python 数据科学基础 - 第二版》,2016 年 10 月
-
NetworkX 教程:
networkx.github.io/documentation/stable/tutorial.html
第六章:优化连续函数
本章描述了如何通过遗传算法解决连续搜索空间优化问题。我们将首先描述常用于基于实数种群的遗传算法的染色体和遗传操作符,并介绍Python 中的分布式进化算法(DEAP)框架在该领域提供的工具。接下来,我们将通过几个实际案例,讲解连续函数优化问题及其基于 Python 的解决方案,这些案例包括Eggholder 函数的优化、Himmelblau 函数的优化以及Simionescu 函数的约束优化。在此过程中,我们将学习如何利用细分和共享来寻找多个解,并处理约束条件。
本章结束时,您将能够执行以下操作:
-
了解用于实数的染色体和遗传操作符
-
使用 DEAP 优化连续函数
-
优化 Eggholder 函数
-
优化 Himmelblau 函数
-
使用 Simionescu 函数进行约束优化
技术要求
在本章中,我们将使用 Python 3 并配合以下支持库:
-
deap
-
numpy
-
matplotlib
-
seaborn
重要提示
如果使用我们提供的requirements.txt文件(请参见第三章),这些库已包含在您的环境中。
本章中使用的程序可以在本书的 GitHub 仓库中找到,链接如下:
查看以下视频,看看代码的实际效果:
用于实数的染色体和遗传操作符
在前几章中,我们关注的是本质上处理状态评估和状态间转换的搜索问题。因此,这些问题的解决方案最好用二进制或整数参数的列表(或数组)表示。与此不同,本章讨论的是解空间为连续的问题,即解由实数(浮动点数)构成。如我们在第二章中提到的,理解遗传算法的关键组成部分,发现用二进制或整数列表表示实数远非理想,反而使用实值数的列表(或数组)被认为是一种更简单、更好的方法。
重述第二章中的示例,如果我们有一个涉及三个实值参数的问题,那么染色体将如下所示:
[x 1, x 2, x 3]
在这里,x 1、x 2、x 3 表示实数,例如以下值:
[1.23, 7.2134, -25.309] 或者 [-30.10, 100.2, 42.424]
此外,我们提到过,虽然各种选择方法对于整数型和实值型染色体的工作方式相同,但实值编码的染色体需要专门的交叉和变异方法。这些算子通常是逐维应用的,示例如下。
假设我们有两个父代染色体:父 x = [x 1, x 2, x 3] 和父 y = [y 1, y 2, y 3]。由于交叉操作是分别应用于每个维度,因此将创建一个后代 [o 1, o 2, o 3],如下所示:
-
o 1 是 x 1 和 y 1 之间交叉算子的结果。
-
o 2 是 x 2 和 y 2 之间交叉算子的结果。
-
o 3 是 x 3 和 y 3 之间交叉算子的结果。
同样,变异算子将分别应用于每个维度,使得每个组件 o 1、o 2 和 o 3 都可以进行变异。
一些常用的实值算子如下:
-
混合交叉(也称为BLX),其中每个后代是从父母之间创建的以下区间中随机选择的:
[父 x − α(父 y − 父 x),父 y + α(父 y − 父 x)]
α值通常设置为 0.5,导致选择区间的宽度是父母区间的两倍。
-
模拟二进制交叉(SBX),其中使用以下公式从两个父代生成两个后代,确保后代值的平均值等于父代值的平均值:
后代 1 = 1 _ 2 [(1 + β)父 x + (1 − β)父 y]
后代 2 = 1 _ 2 [(1 − β)父 x + (1 + β)父 y]
β的值,也称为扩展因子,是通过随机选择的一个值和预定的参数η(eta)、分布指数或拥挤因子的组合计算得到的。随着η值的增大,后代将更倾向于与父母相似。常见的η值在 10 到 20 之间。
-
正态分布(或高斯)变异,其中原始值被替换为使用正态分布生成的随机数,并且有预定的均值和标准差。
在下一节中,我们将看到 DEAP 框架如何支持实值编码的染色体和遗传算子。
使用 DEAP 优化连续函数
DEAP 框架可以用来优化连续函数,方式与我们之前解决离散搜索问题时非常相似。所需的只是一些细微的修改。
对于染色体编码,我们可以使用一个浮动点数的列表(或数组)。需要注意的一点是,DEAP 现有的遗传算子将使用numpy.ndarray类,因为这些对象是通过切片的方式操作的,以及它们之间的比较方式。
使用 numpy.ndarray 类型的个体将需要相应地重新定义遗传操作符。关于这方面的内容,可以参考 DEAP 文档中的 从 NumPy 继承 部分。出于这个原因,以及性能方面的考虑,通常建议在使用 DEAP 时使用 普通 的 Python 列表或浮点数数组。
至于实数编码的遗传操作符,DEAP 框架提供了多个现成的实现,包含在交叉和变异模块中:
-
cxBlend() 是 DEAP 的 混合交叉 实现,使用 alpha 参数作为 α 值。
-
cxSimulatedBinary() 实现了 模拟二进制交叉,使用 eta 参数作为 η(拥挤因子)值。
-
mutGaussian() 实现了 正态分布变异,使用 mu 和 sigma 参数分别作为均值和标准差的值。
此外,由于连续函数的优化通常在特定的 有界区域 内进行,而不是在整个空间中,DEAP 提供了几个接受边界参数的操作符,确保生成的个体位于这些边界内:
-
cxSimulatedBinaryBounded() 是 cxSimulatedBinary() 操作符的有界版本,接受 low 和 up 参数,分别作为搜索空间的下界和上界。
-
mutPolynomialBounded() 是一个有界的 变异 操作符,它使用多项式函数(而不是高斯函数)作为概率分布。该操作符还接受 low 和 up 参数,分别作为搜索空间的下界和上界。此外,它使用 eta 参数作为拥挤因子,较高的值会使变异体接近原始值,而较小的值则会生成与原始值差异较大的变异体。
在下一节中,我们将演示在优化经典基准函数时使用有界操作符的方法。
优化 Eggholder 函数
Eggholder 函数,如下图所示,常被用作函数优化算法的基准。由于具有大量局部最小值,这使得找到该函数的单一 全局最小值 成为一项艰巨的任务,正因如此,它呈现出蛋托形状:

图 6.1:Eggholder 函数
来源:en.wikipedia.org/wiki/File:Eggholder_function.pdf
该函数的数学表达式如下:
f(x, y) = − (y + 47) ⋅ sin √ ___________ | x _ 2 + (y + 47)| − x ⋅ sin √ ___________ |x − (y + 47)|
它通常在每个维度的搜索空间范围 [-512, 512] 上进行评估。已知该函数的全局最小值位于 x=512, y = 404.2319,此时函数值为 -959.6407。
在接下来的小节中,我们将尝试使用遗传算法方法找到全局最小值。
使用遗传算法优化 Eggholder 函数
我们为优化 Eggholder 函数创建的基于遗传算法的程序位于以下链接的01_optimize_eggholder.py Python 程序中:
以下步骤突出显示了该程序的主要部分:
-
程序开始时设置函数常量,即输入维度的数量(2,因为该函数在x-y平面上定义),以及前面提到的搜索空间边界:
DIMENSIONS = 2 # number of dimensions # boundaries, same for all dimensions BOUND_LOW, BOUND_UP = -512.0, 512.0 -
由于我们处理的是受特定边界限制的浮动点数,接下来我们定义一个辅助函数来生成在给定范围内均匀分布的随机浮动点数:
注
这个函数假设所有维度的上限和下限是相同的。
def randomFloat(low, up):
return [random.uniform(l, u) for l, \
u in zip([low] * DIMENSIONS, [up] * DIMENSIONS)]
-
接下来,我们定义attrFloat操作符。该操作符利用先前的辅助函数在给定边界内创建一个单一的随机浮动点数。然后,attrFloat操作符由individualCreator操作符使用,用于创建随机个体。接着是populationCreator,它可以生成所需数量的个体:
toolbox.register("attrFloat", randomFloat, BOUND_LOW, BOUND_UP) toolbox.register("individualCreator", tools.initIterate, \ creator.Individual, toolbox.attrFloat) toolbox.register("populationCreator", tools.initRepeat, \ list, toolbox.individualCreator) -
由于待最小化的对象是 Eggholder 函数,我们直接使用它作为适应度评估函数。由于个体是一个浮动点数的列表,维度(或长度)为 2,我们相应地从个体中提取x和y值,然后计算该函数:
def eggholder(individual): x = individual[0] y = individual[1] f = ( -(y + 47.0) * np.sin(np.sqrt(abs(x / 2.0 + (y + 47.0)))) - x * np.sin(np.sqrt(abs(x - (y + 47.0)))) ) return f, # return a tuple toolbox.register("evaluate", eggholder) -
接下来是遗传操作符。由于选择操作符与个体类型无关,并且到目前为止我们在使用锦标赛选择(锦标赛大小为 2)结合精英主义方法方面经验良好,因此我们将在此继续使用它。另一方面,交叉和变异操作符需要针对给定边界内的浮动点数进行专门化,因此我们使用 DEAP 提供的cxSimulatedBinaryBounded操作符进行交叉,使用mutPolynomialBounded操作符进行变异:
# Genetic operators: toolbox.register("select", tools.selTournament, tournsize=2) toolbox.register("mate", tools.cxSimulatedBinaryBounded, \ low=BOUND_LOW, up=BOUND_UP, eta=CROWDING_FACTOR) toolbox.register("mutate", tools.mutPolynomialBounded, \ low=BOUND_LOW, up=BOUND_UP, eta=CROWDING_FACTOR, \ indpb=1.0/DIMENSIONS) -
如我们多次操作所示,我们使用了修改过的 DEAP 简单遗传算法流程,我们在其中加入了精英主义——保留最好的个体(名人堂成员),并将它们传递到下一代,不受遗传操作符的影响:
population, logbook = elitism.eaSimpleWithElitism(population, toolbox, cxpb=P_CROSSOVER, mutpb=P_MUTATION, ngen=MAX_GENERATIONS, stats=stats, halloffame=hof, verbose=True) -
我们将从以下遗传算法设置参数开始。由于 Eggholder 函数可能有些难以优化,考虑到低维度的数量,我们使用相对较大的种群大小:
# Genetic Algorithm constants: POPULATION_SIZE = 300 P_CROSSOVER = 0.9 P_MUTATION = 0.1 MAX_GENERATIONS = 300 HALL_OF_FAME_SIZE = 30 -
除了之前的常规遗传算法常数外,我们现在需要一个新的常数——拥挤因子(eta),它被交叉和变异操作使用:
CROWDING_FACTOR = 20.0
重要提示
也可以为交叉和变异分别定义不同的拥挤因子。
我们终于准备好运行程序了。使用这些设置得到的结果如下所示:
-- Best Individual = [512.0, 404.23180541839946]
-- Best Fitness = -959.6406627208509
这意味着我们已经找到了全局最小值。
如果我们查看程序生成的统计图(如下所示),可以看出算法一开始就找到了某些局部最小值,随后进行了小幅度的增量改进,直到最终找到了全局最小值:

图 6.2: 优化 Eggholder 函数的第一个程序的统计数据
一个有趣的区域是第 180 代左右——让我们在下一个小节中进一步探讨。
通过增加变异率来提高速度
如果我们放大适应度轴的下部区域,会注意到在第 180 代左右,最佳结果(红线)有了相对较大的改善,同时平均结果(绿线)发生了较大波动:

图 6.3: 第一个程序的统计图放大部分
解释这个现象的一种方式是,或许引入更多噪声能够更快地得到更好的结果。这可能是我们之前讨论过的探索与开发原则的另一种表现——增加探索(在图中表现为噪声)可能帮助我们更快地找到全局最小值。增加探索度的一个简单方法是提高变异的概率。希望使用精英主义——保持最佳结果不变——可以防止我们过度探索,这会导致类似随机搜索的行为。
为了验证这个想法,我们将变异概率从 0.1 提高到 0.5:
P_MUTATION = 0.5
运行修改后的程序后,我们再次找到了全局最小值,但速度要快得多,从输出结果以及接下来展示的统计图中可以明显看出,红线(最佳结果)很快就达到了最优,而平均分数(绿色)比之前更嘈杂,并且离最佳结果更远:

图 6.4: 优化 Eggholder 函数的程序统计数据,变异概率增大
我们在处理下一个基准函数——Himmelblau 函数时会牢记这一点。
优化 Himmelblau 函数
另一个常用的优化算法基准函数是 Himmelblau 函数,如下图所示:

图 6.5: Himmelblau 函数
来源:commons.wikimedia.org/wiki/File:Himmelblau_function.svg
图片由 Morn the Gorn 提供
该函数可以用以下数学表达式表示:
f(x, y) = (x 2 + y − 11) 2 + (x + y 2 − 7) 2
它通常在每个维度边界为[-5, 5]的搜索空间中进行评估。
尽管与 Eggholder 函数相比,这个函数看起来更简单,但它引起了人们的兴趣,因为它是多模态的;换句话说,它有多个全局最小值。准确来说,这个函数有四个全局最小值,值为 0,分别位于以下位置:
-
x=3.0, y=2.0
-
x=−2.805118, y=3.131312
-
x=−3.779310, y=−3.283186
-
x=3.584458, y=−1.848126
这些位置在以下的函数等高线图中进行了描述:

图 6.6:Himmelblau 函数的等高线图
来源:commons.wikimedia.org/wiki/File:Himmelblau_contour.svg
图片由 Nicoguaro 提供
在优化多模态函数时,我们通常希望找到所有(或大多数)最小值的位置。然而,让我们先从找到一个最小值开始,这将在下一小节中完成。
使用遗传算法优化 Himmelblau 函数
我们为找到 Himmelblau 函数的单一最小值所创建的基于遗传算法的程序位于02_optimize_himmelblau.py Python 程序中,具体位置见以下链接:
该程序与我们用来优化 Eggholder 函数的程序类似,下面列出了几个主要的区别:
-
我们为此函数设定了边界为[-5.0, 5.0]:
BOUND_LOW, BOUND_UP = -5.0, 5.0 # boundaries for all dimensions -
现在我们使用 Himmelblau 函数作为适应度评估器:
def himmelblau(individual): x = individual[0] y = individual[1] f = (x ** 2 + y - 11) ** 2 + (x + y ** 2 - 7) ** 2 return f, # return a tuple toolbox.register("evaluate", himmelblau) -
由于我们优化的函数有多个最小值,因此观察运行结束后找到的解的分布可能很有趣。因此,我们添加了一个散点图,显示了四个全局最小值的位置以及最终种群在同一x-y平面上的分布:
plt.figure(1) globalMinima = [[3.0, 2.0], [-2.805118, 3.131312], [-3.779310, -3.283186], [3.584458, -1.848126]] plt.scatter(*zip(*globalMinima), marker='X', color='red', zorder=1) plt.scatter(*zip(*population), marker='.', color='blue', zorder=0) -
我们还打印了名人堂成员——在运行过程中找到的最佳个体:
print("- Best solutions are:") for i in range(HALL_OF_FAME_SIZE): print(i, ": ", hof.items[i].fitness.values[0], " -> ", hof.items[i])
运行程序后,结果显示我们找到了四个最小值中的一个(x=3.0, y=2.0):
-- Best Individual = [2.9999999999987943, 2.0000000000007114]
-- Best Fitness = 4.523490304795033e-23
名人堂成员的输出表明它们都代表相同的解:
- Best solutions are:
0 : 4.523490304795033e-23 -> [2.9999999999987943, 2.0000000000007114]
1 : 4.523732642865117e-23 -> [2.9999999999987943, 2.000000000000697]
2 : 4.523900512465748e-23 -> [2.9999999999987943, 2.0000000000006937]
3 : 4.5240633333565856e-23 -> [2.9999999999987943, 2.00000000000071]
...
下图展示了整个种群的分布,进一步确认了遗传算法已经收敛到四个函数最小值中的一个——即位于(x=3.0, y=2.0)的最小值:

图 6.7:第一次运行结束时种群的散点图,显示了四个函数的最小值
此外,可以明显看出,种群中的许多个体具有我们找到的最小值的x或y分量。
这些结果代表了我们通常从遗传算法中期望的结果——识别全局最优解并向其收敛。由于在此情况下我们有多个最小值,因此预计算法会收敛到其中一个。最终会收敛到哪个最小值,主要取决于算法的随机初始化。正如你可能记得的,我们迄今为止在所有程序中都使用了固定的随机种子(值为 42):
RANDOM_SEED = 42
random.seed(RANDOM_SEED)
这样做是为了使结果具有可重复性;然而,在现实中,我们通常会为不同的运行使用不同的随机种子值,方法是注释掉这些行或显式地将常量设置为不同的值。
例如,如果我们将种子值设置为 13,我们将得到解(x=−2.805118, y=3.131312),如下图所示:

图 6.8:第二次运行结束时种群的散点图,显示了四个函数的最小值
如果我们将种子值更改为 17,程序执行将得到解(x=3.584458, y=−1.848126),如下图所示:

图 6.9:第三次运行结束时种群的散点图,显示了四个函数的最小值
然而,如果我们想在一次运行中找到所有的全局最小值呢?正如我们将在下一小节中看到的,遗传算法为我们提供了一种追求这一目标的方法。
使用分区和共享来寻找多个解
在第二章《理解遗传算法的关键组件》中,我们提到过遗传算法中的分区和共享模拟了自然环境被划分为多个子环境或生态位的方式。这些生态位由不同的物种或子种群填充,利用每个生态位中独特的资源,而在同一生态位中的个体则必须竞争相同的资源。在遗传算法中实现共享机制将鼓励个体探索新的生态位,并可用于寻找多个最优解,每个解都被认为是一个生态位。实现共享的常见方法是将每个个体的原始适应度值与所有其他个体的距离的(某些函数的)合并值相除,从而通过在个体之间共享局部资源来有效地惩罚拥挤的种群。
让我们尝试将这个思路应用于 Himmelblau 函数的优化过程,看看它是否能帮助在一次运行中找到所有四个极小值。这个尝试实现于03_optimize_himmelblau_sharing.py程序中,位于以下链接:
该程序基于之前的程序,但我们必须做了一些重要的修改,描述如下:
-
首先,实现共享机制通常需要我们优化一个产生正适应度值的函数,并寻找最大值,而不是最小值。这使我们能够通过划分原始适应度值来减少适应度,并实际在相邻个体之间共享资源。由于 Himmelblau 函数产生的值介于 0 到(大约)2,000 之间,我们可以使用一个修改后的函数,该函数返回 2,000 减去原始值,这样可以保证所有函数值都是正的,同时将极小值转换为极大值,返回值为 2,000。由于这些点的位置不会改变,找到它们仍然能达到我们最初的目的:
def himmelblauInverted(individual): x = individual[0] y = individual[1] f = (x ** 2 + y - 11) ** 2 + (x + y ** 2 - 7) ** 2 return 2000.0 - f, # return a tuple toolbox.register("evaluate", himmelblauInverted) -
为了完成转换,我们将适应度策略重新定义为最大化策略:
creator.create("FitnessMax", base.Fitness, weights=(1.0,)) -
为了实现共享,我们首先创建了两个额外的常量:
DISTANCE_THRESHOLD = 0.1 SHARING_EXTENT = 5.0 -
接下来,我们需要实现共享机制。一个便捷的实现位置是在选择遗传算子中。选择算子是检查所有个体适应度值并用于选择下一代父母的位置。这使得我们能够注入一些代码,在选择操作发生之前重新计算这些适应度值,然后在继续之前恢复原始的适应度值,以便进行跟踪。为了实现这一点,我们实现了一个新的selTournamentWithSharing()函数,它与我们一直使用的原始tools.selTournament()函数具有相同的函数签名:
def selTournamentWithSharing(individuals, k, tournsize, fit_attr="fitness"):该函数首先将原始的适应度值存放在一旁,以便稍后可以恢复。接着,它遍历每个个体,通过计算一个数字
sharingSum来决定如何划分其适应度值。这个和是通过计算当前个体与种群中每个其他个体位置之间的距离来累加的。如果距离小于DISTANCE_THRESHOLD常量定义的阈值,则会将以下值加到累积和中:1 − 𝒹𝒾𝓈𝓉𝒶𝓃𝒸ℯ ___________________ DISTANCE − THRESHOLD × 1 ______________ SHARING − EXTENT
这意味着在以下情况下,适应度值的下降会更大:
-
个体之间的(归一化)距离较小
-
SHARING_EXTENT常数的值较大
在重新计算每个个体的适应度值后,使用新的适应度值进行锦标赛选择:
selected = tools.selTournament(individuals, k, tournsize, fit_attr)最后,检索原始适应度值:
for i, ind in enumerate(individuals): ind.fitness.values = origFitnesses[i], -
-
最后,我们添加了一个图表,展示了最佳个体——名人堂成员——在x-y平面上的位置,并与已知的最优位置进行对比,类似于我们对整个种群所做的操作:
plt.figure(2) plt.scatter(*zip(*globalMaxima), marker='x', color='red', zorder=1) plt.scatter(*zip(*hof.items), marker='.', color='blue', zorder=0)
当我们运行这个程序时,结果并没有让人失望。通过检查名人堂成员,似乎我们已经找到了所有四个最优位置:
- Best solutions are:
0 : 1999.9997428476076 -> [3.00161237138945, 1.9958270919300878]
1 : 1999.9995532774788 -> [3.585506608049694, -1.8432407550446581]
2 : 1999.9988186889173 -> [3.585506608049694, -1.8396197402430106]
3 : 1999.9987642838498 -> [-3.7758887140006174, -3.285804345540637]
4 : 1999.9986563457114 -> [-2.8072634380293766, 3.125893564009283]
...
以下图示展示了名人堂成员的分布,进一步证实了这一点:

图 6.10:使用生态位分割法时,在运行结束时最佳解的散点图,以及四个函数的最小值。
同时,展示整个种群分布的图表表明,种群是如何围绕四个解散布的:

图 6.11:使用生态位分割法时,在运行结束时种群的散点图,以及四个函数的最小值。
尽管这看起来令人印象深刻,但我们需要记住,我们所做的事情在实际情况中可能更难以实现。首先,我们对选择过程所做的修改增加了计算复杂度和算法的耗时。此外,通常需要增加种群规模,以便它能够充分覆盖所有感兴趣的区域。在某些情况下,共享常数的值可能很难确定——例如,如果我们事先不知道各个峰值之间可能有多近。然而,我们可以始终使用这一技术大致确定感兴趣区域,然后使用标准版本的算法进一步探索每一个区域。
寻找多个最优点的另一种方法属于约束优化的范畴,这是下一节的内容。
Simionescu 的函数与约束优化
初看之下,Simionescu 的函数可能看起来并不特别有趣。然而,它附带的约束条件使得它在处理时既富有挑战性,又令人赏心悦目。
该函数通常在每个维度由[-1.25, 1.25]限定的搜索空间内进行评估,可以用以下数学表达式表示:
f(x, y) = 0.1xy
在这里,x, y的值满足以下条件:
x 2 + y 2 ≤ [1 + 0.2 ⋅ cos(8 ⋅ arctan x _ y )] 2
该约束有效地限制了被认为对该函数有效的x和y值。结果如下图所示:

图 6.12:受约束的 Simionescu 函数的轮廓图
来源:commons.wikimedia.org/wiki/File:Simionescu%27s_function.PNG
图片来自 Simiprof
花朵状的边界是由约束所形成的,而轮廓的颜色表示实际值——红色表示最高值,紫色表示最低值。如果没有约束,最小值点将位于(1.25, -1.25)和(-1.25, 1.25)的位置。然而,在应用约束后,函数的全局最小值位于以下位置:
-
x=0.84852813, y=–0.84852813
-
x=−0.84852813, y=0.84852813
这些代表了包含紫色轮廓的两个相对花瓣的尖端。两个最小值的评估结果均为-0.072。
在接下来的小节中,我们将尝试使用实值编码的遗传算法方法来寻找这些最小值。
受约束优化与遗传算法
我们已经在第五章《约束满足》中处理过约束问题,当时我们讨论了搜索问题中的约束条件。然而,虽然搜索问题为我们呈现了无效状态或组合,但在这里,我们需要处理的是连续空间中的约束,这些约束被定义为数学不等式。
然而,两个案例的处理方法相似,差异在于实现方式。让我们重新回顾这些方法:
-
最好的方法是在可能的情况下消除约束违规的可能性。实际上,在本章中我们一直在这样做,因为我们使用了带有边界的区域来处理函数。这些实际上是对每个输入变量的简单约束。我们通过在给定边界内生成初始种群,并利用如cxSimulatedBinaryBounded()等有界遗传算子,使得结果保持在给定的边界内。不幸的是,当约束比仅仅是输入变量的上下限更复杂时,这种方法可能难以实现。
-
另一种方法是丢弃违反任何给定约束条件的候选解。正如我们之前提到的,这种方法会导致这些解中包含的信息丧失,并可能显著减慢优化过程的速度。
-
下一种方法是修复任何违反约束的候选解,通过修改它使其不再违反约束。这可能会证明很难实现,同时也可能导致显著的信息丧失。
-
最后,适用于我们在第五章中的方法,约束满足,是通过降低违反约束的候选解的得分并使其不那么可取来惩罚违反约束的解。对于搜索问题,我们通过创建一个成本函数来实现这一方法,该函数为每个约束违反加上一个固定的成本。在连续空间的情况下,我们可以使用固定的惩罚,也可以根据违反约束的程度增加惩罚。
当采取最后一种方法——对约束违反进行惩罚——时,我们可以利用 DEAP 框架提供的一个特性,即惩罚函数,我们将在下一小节中演示这一点。
使用遗传算法优化 Simionescu 函数
我们为优化 Simionescu 函数创建的基于遗传算法的程序位于04_optimize_simionescu.py Python 程序中,链接如下:
这个程序与我们在本章中第一次使用的程序非常相似,最初是为 Eggholder 函数创建的,具有以下突出差异:
-
设置边界的常量已调整,以匹配 Simionescu 函数的域:
BOUND_LOW, BOUND_UP = -1.25, 1.25 -
此外,一个新的常量决定了违反约束时的固定惩罚(或成本):
PENALTY_VALUE = 10.0 -
适应度现在由 Simionescu 函数的定义决定:
def simionescu(individual): x = individual[0] y = individual[1] f = 0.1 * x * y return f, # return a tuple toolbox.register("evaluate",simionescu) -
有趣的部分从这里开始:我们现在定义一个新的feasible()函数,该函数通过约束条件指定有效的输入域。对于符合约束条件的x, y值,该函数返回True,否则返回False:
def feasible(individual): x = individual[0] y = individual[1] return x**2 + y**2 <= (1 + 0.2 * math.cos(8.0 * math.atan2(x, y)))**2 -
然后,我们使用 DEAP 的toolbox.decorate()操作符与tools.DeltaPenalty()函数结合,以修改(装饰)原始的适应度函数,使得每当不满足约束条件时,适应度值会受到惩罚。DeltaPenalty()接受feasible()函数和固定惩罚值作为参数:
toolbox.decorate("evaluate", tools.DeltaPenalty( feasible,PENALTY_VALUE))
重要提示
DeltaPenalty()函数还可以接受第三个参数,表示距离可行区域的距离,使得惩罚随着距离的增加而增加。
现在,程序已经可以使用了!结果表明,我们确实找到了已知的两个最小值之一:
-- Best Individual = [0.8487712463169383, -0.8482833185888866]
-- Best Fitness = -0.07199984895485578
第二个位置怎么样?继续阅读——我们将在下一小节中寻找它。
使用约束条件找到多个解
在本章早些时候,优化 Himmelblau 函数时,我们寻求多个最小解,并观察到两种可能的做法——一种是改变随机种子,另一种是使用分区和共享。在这里,我们将展示第三种方法,通过...约束来实现!
我们为 Himmelblau 函数使用的分区技术有时被称为并行分区,因为它试图同时定位多个解。正如我们之前提到的,它存在一些实际缺陷。另一方面,串行分区(或顺序分区)是一种每次寻找一个解的方法。为了实现串行分区,我们像往常一样使用遗传算法来找到最佳解。然后我们更新适应度函数,以便惩罚已找到解的区域,从而鼓励算法探索问题空间中的其他区域。这一过程可以重复多次,直到没有找到额外的可行解。
有趣的是,通过对搜索空间施加约束,惩罚靠近先前找到的解的区域是可实现的,正如我们刚刚学会如何向函数应用约束,我们可以利用这些知识来实现串行分区,示例如下:
为了找到 Simionescu 函数的第二个最小值,我们创建了05_optimize_simionescu_second.py Python 程序,位于以下链接:
该程序几乎与之前的程序相同,只做了以下几个小修改:
-
我们首先添加了一个常数,用于定义距离阈值,该阈值用于与先前找到的解的距离——新解如果距离任何旧解小于此阈值,则会受到惩罚:
DISTANCE_THRESHOLD = 0.1 -
我们接着通过使用一个带有多个子句的条件语句,向feasible()函数的定义中添加了第二个约束条件。新的约束适用于距离已经找到的解(x=0.848, y = -0.848)阈值更近的输入值:
def feasible(individual): x = individual[0] y = individual[1] if x**2 + y**2 > (1 + 0.2 * math.cos( 8.0 * math.atan2(x, y)) )**2: return False elif (x - 0.848)**2 + (y + 0.848)**2 < DISTANCE_THRESHOLD**2: return False else: return True
运行该程序时,结果表明我们确实找到了第二个最小值:
-- Best Individual = [-0.8473430282562487, 0.8496942440090975]
-- Best Fitness = -0.07199824938105727
鼓励你将这个最小点作为另一个约束添加到feasible()函数中,并验证再次运行程序时,不会找到输入空间中任何其他同样的最小值位置。
总结
在本章中,我们介绍了连续搜索空间优化问题,以及如何使用遗传算法表示并解决这些问题,特别是通过利用 DEAP 框架。接着,我们探索了几个实际的连续函数优化问题——埃格霍尔德函数、希梅尔布劳函数和西蒙内斯库函数——以及它们基于 Python 的解决方案。此外,我们还讲解了寻找多重解和处理约束的方法。
在本书接下来的四章中,我们将演示我们目前所学的各种技术如何应用于解决机器学习(ML)和人工智能(AI)相关问题。这些章节的第一章将提供一个关于监督学习(SL)的快速概述,并展示遗传算法如何通过选择给定数据集的最相关部分来改善学习模型的结果。
进一步阅读
如需更多信息,请参考以下资源:
-
数学优化:寻找 函数的最小值:
-
优化测试函数 和数据集:
-
约束优化简介:
web.stanford.edu/group/sisl/k12/optimization/MO-unit3-pdfs/3.1introandgraphical.pdf -
DEAP 中的约束处理:
deap.readthedocs.io/en/master/tutorials/advanced/constraints.html
第三部分:遗传算法在人工智能中的应用
本部分重点介绍了使用遗传算法增强各种人工智能任务,包括机器学习和自然语言处理。首先展示了这些算法如何通过最优特征选择增强回归和分类任务中的监督学习模型。接着我们探讨了通过超参数调优来提高模型性能,将传统的网格搜索方法与遗传算法方法进行比较。然后我们将焦点转向人工神经网络架构的优化,利用 Iris 数据集来说明网络结构和超参数的联合优化。在强化学习领域,遗传算法被应用于解决 Gymnasium 的 MountainCar 和 CartPole 挑战,而在自然语言处理方面,我们可以看到遗传算法在解决谜语游戏和文档分类中的应用。最后,我们探讨了遗传算法在创建数据集中的“假设”场景的应用,采用反事实分析在可解释人工智能和因果推断中的使用。
本部分包含以下章节:
-
第七章**, 使用特征选择增强机器学习模型
-
第八章**, 机器学习模型的超参数调优
-
第九章**, 深度学习网络的架构优化
-
第十章**, 使用遗传算法的强化学习
-
第十一章**, 自然语言处理
-
第十二章**, 可解释人工智能与反事实分析
第七章:使用特征选择增强机器学习模型
本章介绍了如何通过遗传算法选择提供输入数据中的最佳特征,从而提高 有监督机器学习 模型的性能。我们将首先简要介绍机器学习,然后描述两种主要的有监督学习任务——回归 和 分类。接着,我们将讨论在这些模型的性能方面,特征选择的潜在好处。随后,我们将演示如何利用遗传算法确定由 Friedman-1 测试 回归问题生成的真正特征。然后,我们将使用真实的 Zoo 数据集 创建一个分类模型,并通过遗传算法来隔离任务的最佳特征,从而提高其准确性。
本章我们将涵盖以下主题:
-
理解有监督机器学习的基本概念,以及回归和分类任务
-
理解特征选择对有监督学习模型性能的影响
-
使用通过 DEAP 框架编码的遗传算法进行特征选择,增强 Friedman-1 测试回归问题的回归模型性能
-
使用通过 DEAP 框架编码的遗传算法进行特征选择,增强 Zoo 数据集分类问题的分类模型性能
我们将从对有监督机器学习的快速回顾开始本章内容。如果你是经验丰富的数据科学家,可以跳过这些入门部分。
技术要求
在本章中,我们将使用 Python 3 和以下支持库:
-
deap
-
numpy
-
pandas
-
matplotlib
-
seaborn
-
scikit-learn – 本章介绍
重要提示
如果你使用我们提供的 requirements.txt 文件(参见 第三章),这些库已经包含在你的环境中。
此外,我们还将使用 UCI Zoo 数据集(archive.ics.uci.edu/ml/datasets/zoo)。
本章中使用的程序可以在本书的 GitHub 仓库中找到,地址为 github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/tree/main/chapter_07。
查看以下视频,看看代码是如何运行的:
有监督的机器学习
机器学习 这个术语通常指的是一个接收输入并生成输出的计算机程序。我们的目标是训练这个程序,也称为 模型,使其能够对给定的输入生成正确的输出,而无需明确 编程。
在此训练过程中,模型通过调整其内部参数来学习输入与输出之间的映射。训练模型的一种常见方法是为它提供一组已知正确输出的输入。对于这些输入,我们告诉模型正确的输出是什么,以便它可以调整或调优自己,最终为每个给定输入产生期望的输出。这种调优是学习过程的核心。
多年来,已经开发出许多类型的机器学习模型。每种模型都有其独特的内部参数,这些参数可以影响输入与输出之间的映射,并且这些参数的值可以进行调整,如下图所示:

图 7.1:机器学习模型的参数调整
例如,如果模型正在实现一个决策树,它可能包含几个IF-THEN语句,可以按如下方式表示:
IF <input value> IS LESS THEN <some threshold value>
THEN <go to some target branch>
在这种情况下,阈值和目标分支的身份都是可以在学习过程中调整或调优的参数。
为了调整内部参数,每种类型的模型都有一个相应的学习算法,该算法会遍历给定的输入和输出值,并尝试使每个输入的输出与给定的输出相匹配。为了实现这一目标,典型的学习算法会衡量实际输出与期望输出之间的差异(也称为误差,或更广义的损失);然后,算法会通过调整模型的内部参数来最小化这个误差。
监督学习的两种主要类型是分类和回归,将在以下小节中进行描述。
分类
在执行分类任务时,模型需要决定某个输入属于哪个类别。每个类别由一个单独的输出(称为标签)表示,而输入被称为特征:

图 7.2:机器学习分类模型
例如,在著名的鸢尾花数据集(archive.ics.uci.edu/ml/datasets/Iris)中,有四个特征:花瓣长度、花瓣宽度、萼片长度和萼片宽度。这些代表了实际鸢尾花的手动测量值。
在输出方面,有三个标签:鸢尾花 Setosa、鸢尾花 Virginica和鸢尾花 Versicolor。这些代表了数据集中三种不同类型的鸢尾花。
当输入值代表从某个鸢尾花中获取的测量值时,我们期望正确标签的输出值变高,而其他两个标签的输出值变低:

图 7.3:鸢尾花分类器示意图
分类任务有许多现实生活中的应用,例如银行贷款和信用卡审批、电子邮件垃圾邮件检测、手写数字识别和人脸识别。本章后面将演示使用动物园数据集进行动物类型分类。
监督学习的第二种主要类型,回归,将在下一个子节中描述。
回归
与分类任务相比,回归任务的模型将输入值映射为单一输出,以提供一个连续值,如下图所示:

图 7.4:机器学习回归模型
给定输入值,模型预计会预测输出的正确值。
回归的实际应用实例包括预测股票价值、葡萄酒质量或房屋市场价格,如下图所示:

图 7.5:房价回归模型
在前面的图像中,输入是描述给定房屋信息的特征,而输出是预测的房屋价值。
有许多类型的模型用于执行分类和回归任务——其中一些将在下一个子节中描述。
监督学习算法
如前所述,每个监督学习模型由一组内部可调参数和一个调整这些参数的算法组成,旨在实现所需结果。
一些常见的监督学习模型/算法如下:
-
支持向量机(SVMs):将给定输入映射为空间中的点,使得属于不同类别的输入通过尽可能大的间隔被分开。
-
决策树:一类利用树状图的算法,其中分支点代表决策,分支代表其后果。
-
随机森林:在训练阶段创建大量决策树,并使用它们输出的组合。
-
人工神经网络:由多个简单节点或神经元组成的模型,这些神经元可以以不同方式互联。每个连接可以有一个权重,控制从一个神经元到下一个神经元的信号强度。
有一些技术可以用来提高和增强这些模型的性能。一种有趣的技术——特征选择——将在下一节中讨论。
监督学习中的特征选择
正如我们在上一节所看到的,监督学习模型接收一组输入,称为特征,并将它们映射到一组输出。假设特征所描述的信息对于确定相应输出的值是有用的。乍一看,似乎我们使用的输入信息越多,正确预测输出的机会就越大。然而,在许多情况下,事实恰恰相反;如果我们使用的一些特征无关紧要或是冗余的,结果可能是模型准确性的(有时是显著的)下降。
特征选择是从给定的所有特征集中选择最有益和最重要的特征的过程。除了提高模型的准确性外,成功的特征选择还可以带来以下优势:
-
模型的训练时间较短。
-
结果训练得到的模型更简单,更易于解释。
-
结果模型可能提供更好的泛化能力,也就是说,它们在处理与训练数据不同的新输入数据时表现更好。
在查看执行特征选择的方法时,遗传算法是一个自然的候选方法。我们将在下一节中演示如何将它们应用于从人工生成的数据集中找到最佳特征。
为 Friedman-1 回归问题选择特征
Friedman-1回归问题由 Friedman 和 Breiman 创建,描述了一个单一的输出值 y,该值是五个输入值 x 0、x 1、x 2、x 3、x 4 和随机生成噪声的函数,按照以下公式:
y(x 0, x 1, x 2, x 3, x 4)
= 10 ∙ sin(π ∙ x 0 ∙ x 1) + 20 (x 2 − 0.5) 2 + 10 x 3 + 5 x 4 + 噪声
∙ N(0, 1)
输入变量 x 0 . .x 4 是独立的,且在区间[0, 1]内均匀分布。公式中的最后一个组成部分是随机生成的噪声。噪声是正态分布的,并与常数噪声相乘,后者决定了噪声的水平。
在 Python 中,scikit-learn(sklearn)库提供了make_friedman1()函数,我们可以使用它来生成包含所需样本数量的数据集。每个样本由随机生成的 x0...x4 值及其对应的计算 y 值组成。然而,值得注意的是,我们可以通过将n_features参数设置为大于五的值,告诉函数向原来的五个特征中添加任意数量的无关输入变量。例如,如果我们将n_features的值设置为 15,我们将得到一个包含原始五个输入变量(或特征)的数据集,这些特征根据前面的公式生成了y值,并且还有另外 10 个与输出完全无关的特征。这可以用于测试各种回归模型对噪声和数据集中无关特征存在的抗干扰能力。
我们可以利用这个功能来测试遗传算法作为特征选择机制的有效性。在我们的测试中,我们将使用make_friedman1()函数创建一个包含 15 个特征的数据集,并使用遗传算法搜索提供最佳性能的特征子集。因此,我们预计遗传算法会选择前五个特征,并去除其余的特征,假设当仅使用相关特征作为输入时,模型的准确性会更好。遗传算法的适应度函数将使用回归模型,对于每一个潜在解,都会使用仅包含选择特征的数据集训练原始特征的子集。
和往常一样,我们将从选择合适的解决方案表示开始,如下一小节所述。
解决方案表示
我们算法的目标是找到一个能够提供最佳性能的特征子集。因此,一个解决方案需要指示哪些特征被选择,哪些被丢弃。一个明显的方法是使用二进制值列表来表示每个个体。列表中的每一项对应数据集中的一个特征。值为 1 表示选择相应的特征,而值为 0 表示该特征未被选择。这与我们在 第四章**, 组合优化中描述的背包 0-1 问题方法非常相似。
解决方案中每个 0 的存在将被转换为从数据集中删除相应特征的数据列,正如我们在下一小节中所看到的那样。
Python 问题表示
为了封装 Friedman-1 特征选择问题,我们创建了一个名为 Friedman1Test 的 Python 类。该类可以在 friedman.py 文件中找到,文件位置在 github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_07/friedman.py。
该类的主要部分如下:
-
类的 init() 方法创建了数据集,具体如下:
self.X, self.y = datasets.make_friedman1( n_samples=self.numSamples, n_features=self.numFeatures, noise=self.NOISE, random_state=self.randomSeed) -
然后,使用 scikit-learn model_selection.train_test_split() 方法将数据划分为两个子集——训练集和验证集:
self.X_train,self.X_validation,self.y_train,self.y_validation = \ model_selection.train_test_split(self.X, self.y, test_size=self.VALIDATION_SIZE, random_state=self.randomSeed)将数据分为 训练集 和 验证集,使我们能够在训练集上训练回归模型,其中为训练提供正确的预测,然后在单独的验证集上测试模型,在验证集中不提供正确的预测,而是将其与模型产生的预测进行比较。通过这种方式,我们可以测试模型是否能够泛化,而不是仅仅记住训练数据。
-
接下来,我们创建回归模型,并选择了 梯度提升回归器 (GBR) 类型。该模型在训练阶段创建了一个 集成(或聚合)决策树:
self.regressor = GradientBoostingRegressor(\ random_state=self.randomSeed)
重要提示
在我们的示例中,我们传递了随机种子,以便回归器可以在内部使用它。通过这种方式,我们可以确保得到的结果是可重复的。
-
该类的 getMSE() 方法用于确定我们为一组选定特征训练的梯度提升回归模型的性能。它接受一个对应于数据集中各特征的二进制值列表——值为 1 表示选择相应特征,值为 0 则表示该特征被丢弃。然后该方法删除训练集和验证集中与未选择特征对应的列:
zeroIndices = [i for i, n in enumerate(zeroOneList) if n == 0] currentX_train = np.delete(self.X_train, zeroIndices, 1) currentX_validation = np.delete(self.X_validation, zeroIndices, 1) -
修改后的训练集——仅包含所选特征——用于训练回归器,而修改后的验证集用于评估回归器的预测:
self.regressor.fit(currentX_train, self.y_train) prediction = self.regressor.predict(currentX_validation) return mean_squared_error(self.y_validation, prediction)这里用于评估回归器的度量叫做 均方误差 (MSE),它计算模型预测值与实际值之间的平均平方差。该度量的 较低 值表示回归器的 更好 性能。
-
类的 main() 方法创建了一个包含 15 个特征的 Friedman1Test 类的实例。然后,它反复使用 getMSE() 方法评估回归器在前 n 个特征上的性能,n 从 1 递增到 15:
for n in range(1, len(test) + 1): nFirstFeatures = [1] * n + [0] * (len(test) - n) score = test.getMSE(nFirstFeatures)
在运行 main 方法时,结果显示,当我们逐一添加前五个特征时,性能有所提高。然而,之后每增加一个特征都会降低回归器的性能:
1 first features: score = 47.553993
2 first features: score = 26.121143
3 first features: score = 18.509415
4 first features: score = 7.322589
5 first features: score = 6.702669
6 first features: score = 7.677197
7 first features: score = 11.614536
8 first features: score = 11.294010
9 first features: score = 10.858028
10 first features: score = 11.602919
11 first features: score = 15.017591
12 first features: score = 14.258221
13 first features: score = 15.274851
14 first features: score = 15.726690
15 first features: score = 17.187479
这一点通过生成的图表进一步说明,图中显示了使用前五个特征时的最小 MSE 值:

图 7.6:Friedman-1 回归问题的误差值图
在接下来的小节中,我们将探讨遗传算法是否能够成功识别这五个特征。
遗传算法解决方案
为了使用遗传算法识别回归测试中最优的特征集,我们创建了 Python 程序01_solve_friedman.py,可以在github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_07/01_solve_friedman.py找到。
提醒一下,这里使用的染色体表示是一个整数列表,值为 0 或 1,表示某个特征是否应被使用或舍弃。从遗传算法的角度来看,这使得我们的任务类似于OneMax问题,或我们之前解决过的背包 0-1问题。不同之处在于适应度函数返回的是回归模型的 MSE,该值是在Friedman1Test类中计算的。
以下步骤描述了我们解决方案的主要部分:
-
首先,我们需要创建一个Friedman1Test类的实例,并设置所需的参数:
friedman = friedman.Friedman1Test(NUM_OF_FEATURES, \ NUM_OF_SAMPLES, RANDOM_SEED) -
由于我们的目标是最小化回归模型的 MSE,因此我们定义了一个单一目标,即最小化适应度策略:
creator.create("FitnessMin", base.Fitness, weights=(-1.0,)) -
由于解是通过一个 0 或 1 的整数值列表来表示的,因此我们使用以下工具箱定义来创建初始种群:
toolbox.register("zeroOrOne", random.randint, 0, 1) toolbox.register("individualCreator",\ tools.initRepeat, creator.Individual, \ toolbox.zeroOrOne, len(friedman)) toolbox.register("populationCreator", tools.initRepeat, \ list, toolbox.individualCreator) -
然后,我们指示遗传算法使用Friedman1Test实例的getMSE()方法来进行适应度评估:
def friedmanTestScore(individual): return friedman.getMSE(individual), # return a tuple toolbox.register("evaluate", friedmanTestScore) -
至于遗传操作符,我们使用锦标赛选择(锦标赛规模为 2),以及专门为二进制列表染色体设计的交叉和变异操作符:
toolbox.register("select", tools.selTournament, tournsize=2) toolbox.register("mate", tools.cxTwoPoint) toolbox.register("mutate", tools.mutFlipBit, \ indpb=1.0/len(friedman)) -
此外,我们继续使用精英主义方法,即名人堂(HOF)成员——当前最优秀的个体——始终不变地传递到下一代:
population, logbook = elitism.eaSimpleWithElitism( population, toolbox, cxpb=P_CROSSOVER, mutpb=P_MUTATION, ngen=MAX_GENERATIONS, stats=stats, halloffame=hof, verbose=True)
通过运行 30 代,种群大小为 30,我们得到以下结果:
-- Best Ever Individual = [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-- Best Ever Fitness = 6.702668910463287
这表明前五个特征被选择出来,以提供我们测试的最佳 MSE(大约为 6.7)。请注意,遗传算法并不假设它要找的特征集是什么,这意味着它并不知道我们在寻找前n个特征的子集。它仅仅是寻找最优的特征子集。
在接下来的章节中,我们将从使用人工生成的数据转向实际数据集,并利用遗传算法为分类问题选择最佳特征。
为分类 Zoo 数据集选择特征
UCI 机器学习库(archive.ics.uci.edu/)为机器学习社区提供了超过 600 个数据集。这些数据集可以用于不同模型和算法的实验。一个典型的数据集包含若干个特征(输入)和期望的输出,通常以列的形式呈现,并且会附有描述这些特征的含义。
在本节中,我们将使用 UCI 动物园数据集(archive.ics.uci.edu/dataset/111/zoo)。此数据集描述了 101 种不同的动物,使用以下 18 个特征:
| 编号 | 特征名称 | 数据类型 |
|---|---|---|
| 1 | 动物名称 | 每个实例唯一 |
| 2 | 毛发 | 布尔值 |
| 3 | 羽毛 | 布尔值 |
| 4 | 鸡蛋 | 布尔值 |
| 5 | 牛奶 | 布尔值 |
| 6 | 空中 | 布尔值 |
| 7 | 水生 | 布尔值 |
| 8 | 捕食者 | 布尔值 |
| 9 | 有齿 | 布尔值 |
| 10 | 脊椎 | 布尔值 |
| 11 | 呼吸 | 布尔值 |
| 12 | 有毒 | 布尔值 |
| 13 | 鳍 | 布尔值 |
| 14 | 腿数 | 数值型(值集合 {0,2,4,5,6,8}) |
| 15 | 尾巴 | 布尔值 |
| 16 | 驯养 | 布尔值 |
| 17 | 猫大小 | 布尔值 |
| 18 | 类型 | 数值型(整数值范围 [1..7]) |
表 7.1:动物园数据集的特征列表
大多数特征是 布尔值(1 或 0),表示某种属性的存在或不存在,如 毛发、鳍 等。第一个特征,动物名称,仅提供附加信息,不参与学习过程。
该数据集用于测试分类任务,其中输入特征需要映射到两个或更多的类别/标签。在这个数据集中,最后一个特征,称为 类型,表示类别,例如,类型 值为 5 表示包括青蛙、蝾螈和蟾蜍在内的动物类别。
总结来说,使用此数据集训练的分类模型将使用特征 2-17(毛发、羽毛、鳍 等)来预测特征 18(动物 类型)的值。
我们再次希望使用遗传算法来选择能够给出最佳预测的特征。我们从创建一个代表分类器的 Python 类开始,该分类器已通过此数据集进行训练。
Python 问题表示
为了封装动物园数据集分类任务中的特征选择过程,我们创建了一个名为 Zoo 的 Python 类。这个类位于 zoo.py 文件中,文件路径为:
这个类的主要部分如下所示:
-
类的 init() 方法从网络加载动物园数据集,同时跳过第一个特征——动物名称,具体如下:
self.data = read_csv(self.DATASET_URL, header=None, usecols=range(1, 18)) -
然后,它将数据分离为输入特征(前 16 列)和结果类别(最后一列):
self.X = self.data.iloc[:, 0:16] self.y = self.data.iloc[:, 16] -
我们不再像上一节那样仅将数据分割为训练集和测试集,而是使用k 折交叉验证。这意味着数据被分割成k个相等的部分,每次评估时,使用(k-1)部分作为训练集,剩余部分作为测试集(或验证集)。在 Python 中,可以使用scikit-learn库的model_selection.KFold()方法轻松实现:
self.kfold = model_selection.KFold( n_splits=self.NUM_FOLDS, random_state=self.randomSeed) -
接下来,我们基于决策树创建一个分类模型。这种类型的分类器在训练阶段创建一个树形结构,将数据集分割成更小的子集,最终生成一个预测:
self.classifier = DecisionTreeClassifier( random_state=self.randomSeed)
重要提示
我们传递一个随机种子,以便它可以被分类器内部使用。这样,我们可以确保获得的结果是可重复的。
-
该类的getMeanAccuracy()方法用于评估分类器在一组选定特征下的性能。类似于Friedman1Test类中的getMSE()方法,该方法接受一个与数据集中的特征对应的二进制值列表——值为1表示选择了对应的特征,而值为0表示丢弃该特征。该方法随后丢弃数据集中与未选择特征对应的列:
zeroIndices = [i for i, n in enumerate(zeroOneList) if n == 0] currentX = self.X.drop(self.X.columns[zeroIndices], axis=1) -
这个修改后的数据集——仅包含选定的特征——随后用于执行k 折交叉验证过程,并确定分类器在数据分区上的表现。我们类中的k值设置为5,因此每次进行五次评估:
cv_results = model_selection.cross_val_score( self.classifier, currentX, self.y, cv=self.kfold, scoring='accuracy') return cv_results.mean()这里用来评估分类器的指标是准确度——即分类正确的案例所占的比例。例如,准确度为 0.85,意味着 85%的案例被正确分类。由于在我们的情况下,我们训练并评估分类器k次,因此我们使用在这些评估中获得的平均(均值)准确度值。
-
该类的主方法创建了一个Zoo类的实例,并使用全一解决方案表示法评估所有 16 个特征的分类器:
allOnes = [1] * len(zoo) print("-- All features selected: ", allOnes, ", accuracy = ", zoo.getMeanAccuracy(allOnes))
在运行类的主方法时,输出显示,在使用所有 16 个特征进行 5 折交叉验证测试我们的分类器后,获得的分类准确度大约为 91%:
-- All features selected: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], accuracy = 0.9099999999999999
在下一个小节中,我们将尝试通过从数据集中选择一个特征子集来提高分类器的准确性,而不是使用所有特征。我们将使用——你猜对了——遗传算法来为我们选择这些特征。
遗传算法解决方案
为了使用遗传算法确定用于 Zoo 分类任务的最佳特征集,我们创建了 Python 程序02_solve_zoo.py,该程序位于github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_07/02_solve_zoo.py。与前一节一样,这里使用的染色体表示是一个整数列表,值为0或1,表示某个特征是否应被使用或丢弃。
以下步骤突出了程序的主要部分:
-
首先,我们需要创建一个Zoo类的实例,并传递我们的随机种子,以确保结果可重复:
zoo = zoo.Zoo(RANDOM_SEED) -
由于我们的目标是最大化分类器模型的准确性,我们定义了一个单一目标,即最大化适应度策略:
creator.create("FitnessMax", base.Fitness, weights=(1.0,)) -
就像在前一节中一样,我们使用以下工具箱定义来创建初始种群,每个个体由0或1整数值组成:
toolbox.register("zeroOrOne", random.randint, 0, 1) toolbox.register("individualCreator", tools.initRepeat, \ creator.Individual, toolbox.zeroOrOne, len(zoo)) toolbox.register("populationCreator", tools.initRepeat, \ list, toolbox.individualCreator) -
然后,我们指示遗传算法使用Zoo实例的getMeanAccuracy()方法进行适应度评估。为此,我们需要进行两个修改:
-
我们排除了未选择任何特征(全零个体)的可能性,因为在这种情况下我们的分类器将抛出异常。
-
我们对每个被使用的特征添加一个小的惩罚,以鼓励选择较少的特征。惩罚值非常小(0.001),因此它仅在两个表现相同的分类器之间起到决胜作用,导致算法偏好使用较少特征的分类器:
def zooClassificationAccuracy(individual): numFeaturesUsed = sum(individual) if numFeaturesUsed == 0: return 0.0, else: accuracy = zoo.getMeanAccuracy(individual) return accuracy - FEATURE_PENALTY_FACTOR * numFeaturesUsed, # return a tuple toolbox.register("evaluate", zooClassificationAccuracy)
-
-
对于遗传算子,我们再次使用锦标赛选择,锦标赛大小为2,并且使用专门针对二进制列表染色体的交叉和变异算子:
toolbox.register("select", tools.selTournament, tournsize=2) toolbox.register("mate", tools.cxTwoPoint) toolbox.register("mutate", tools.mutFlipBit, indpb=1.0/len(zoo)) -
再次,我们继续使用精英主义方法,即 HOF 成员——当前最佳个体——总是被直接传递到下一代,而不做改变:
population, logbook = elitism.eaSimpleWithElitism(population, toolbox, cxpb=P_CROSSOVER, mutpb=P_MUTATION, ngen=MAX_GENERATIONS, stats=stats, halloffame=hof, verbose=True) -
在运行结束时,我们打印出 HOF 的所有成员,以便查看算法找到的最佳结果。我们打印出适应度值(包括特征数量的惩罚)和实际的准确度值:
print("- Best solutions are:") for i in range(HALL_OF_FAME_SIZE): print( i, ": ", hof.items[i], ", fitness = ", hof.items[i].fitness.values[0], ", accuracy = ", zoo.getMeanAccuracy(hof.items[i]), ", features = ", sum(hof.items[i]) )
通过运行该算法 50 代,种群大小为 50,HOF 大小为 5,我们得到了以下结果:
- Best solutions are:
0 : [0, 1, 0, 1, 1, 0, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0] , fitness = 0.964 , accuracy = 0.97 , features = 6
1 : [0, 1, 0, 1, 1, 0, 0, 0, 1, 0, 0, 1, 0, 1, 0, 1] , fitness = 0.963 , accuracy = 0.97 , features = 7
2 : [0, 1, 0, 1, 1, 0, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0] , fitness = 0.963 , accuracy = 0.97 , features = 7
3 : [1, 1, 0, 1, 1, 0, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0] , fitness = 0.963 , accuracy = 0.97 , features = 7
4 : [0, 1, 0, 1, 1, 0, 0, 0, 1, 0, 0, 1, 0, 1, 1, 0] , fitness = 0.963 , accuracy = 0.97 , features = 7
这些结果表明,所有五个最佳解的准确率值均达到了 97%,使用的是从可用的 16 个特征中选择的六个或七个特征。由于对特征数量的惩罚因素,最佳解是由六个特征组成,具体如下:
-
羽毛
-
牛奶
-
空中
-
脊柱
-
鳍
-
尾巴
总结而言,通过从数据集中选择这 16 个特定特征,我们不仅减少了问题的维度,还成功地将模型的准确率从 91%提高到了 97%。如果乍一看这似乎不是一个巨大的提升,那么可以把它看作是将错误率从 9%降低到 3%——在分类性能方面,这是一个非常显著的改进。
摘要
本章中,您将了解到机器学习以及两种主要的有监督机器学习任务——回归和分类。然后,您将了解到特征选择在执行这些任务的模型性能中的潜在好处。本章的核心内容是通过遗传算法如何利用特征选择来提升模型性能的两个演示。在第一个案例中,我们确定了由Friedman-1 测试回归问题生成的真实特征,而在另一个案例中,我们选择了Zoo 分类数据集中最有益的特征。
在下一章中,我们将探讨另一种可能的方式来提升有监督机器学习模型的性能,即超参数调优。
深入阅读
欲了解本章所涉及的更多内容,请参考以下资源:
-
Python 应用监督学习,Benjamin Johnston 和 Ishita Mathur,2019 年 4 月 26 日
-
特征工程简明指南,Sinan Ozdemir 和 Divya Susarla,2018 年 1 月 22 日
-
分类特征选择,M.Dash 和 H.Liu,1997 年:
doi.org/10.1016/S1088-467X(97)00008-5 -
UCI 机器学习 数据集库:
archive.ics.uci.edu/
第八章:机器学习模型的超参数调整
本章介绍了如何通过调整模型的超参数,使用遗传算法来提高监督学习模型的性能。本章将首先简要介绍机器学习中的超参数调整,然后介绍网格搜索的概念。在介绍 Wine 数据集和自适应提升分类器后,二者将在本章中反复使用,我们将展示如何通过传统的网格搜索和遗传算法驱动的网格搜索进行超参数调整。最后,我们将尝试通过直接的遗传算法方法来优化超参数调整结果,从而提升性能。
到本章结束时,你将能够完成以下任务:
-
演示对机器学习中超参数调整概念的熟悉度
-
演示对 Wine 数据集和自适应提升分类器的熟悉度
-
使用超参数网格搜索提升分类器的性能
-
使用遗传算法驱动的超参数网格搜索提升分类器的性能
-
使用直接的遗传算法方法提升分类器的性能,以进行超参数调整
本章将以快速概述机器学习中的超参数开始。如果你是经验丰富的数据科学家,可以跳过引言部分。
技术要求
本章将使用 Python 3,并配备以下支持库:
-
deap
-
numpy
-
pandas
-
matplotlib
-
seaborn
-
scikit-learn
重要说明
如果你使用我们提供的requirements.txt文件(参见第三章),这些库已经包含在你的环境中。
此外,我们将使用 UCI Wine 数据集:archive.ics.uci.edu/ml/datasets/Wine
本章中使用的程序可以在本书的 GitHub 仓库中找到:
查看以下视频,观看代码演示:packt.link/OEBOd
机器学习中的超参数
在第七章《使用特征选择提升机器学习模型》中,我们将监督学习描述为调整(或调整)模型内部参数的程序化过程,以便在给定输入时产生期望的输出。为了实现这一目标,每种类型的监督学习模型都配有一个学习算法,在学习(或训练)阶段反复调整其内部参数。
然而,大多数模型还有另一组参数是在学习发生之前设置的。这些参数被称为 超参数,并且它们影响学习的方式。以下图示了这两类参数:

图 8.1:机器学习模型的超参数调优
通常,超参数有默认值,如果我们没有特别设置,它们将会生效。例如,如果我们查看 scikit-learn 库中 决策树分类器 的实现(scikit-learn.org/stable/modules/generated/sklearn.tree.DecisionTreeClassifier.html),我们会看到几个超参数及其默认值。
以下表格描述了一些超参数:
| 名称 | 类型 | 描述 | 默认值 |
|---|---|---|---|
max_depth |
整数 | 树的最大深度 | 无 |
splitter |
枚举型 | 用于选择每个最佳节点分裂的策略:{'best', 'random'} |
'best' |
min_samples_split |
整数或浮动型 | 分裂内部节点所需的最小样本数 | 2 |
表 8.1:超参数及其详细信息
这些参数每个都会影响决策树在学习过程中构建的方式,它们对学习过程结果的综合影响——从而对模型的表现——可能是显著的。
由于超参数的选择对机器学习模型的性能有着重要影响,数据科学家通常会花费大量时间寻找最佳超参数组合,这个过程称为 超参数调优。一些用于超参数调优的方法将在下一小节中介绍。
超参数调优
寻找超参数良好组合的常见方法是使用 {2, 5, 10} 来设置 max_depth 参数,而对于 splitter 参数,我们选择两个可能的值——{"best", "random"}。然后,我们尝试所有六种可能的组合。对于每个组合,分类器会根据某个性能标准(例如准确度)进行训练和评估。在过程结束时,我们选择出表现最好的超参数组合。
网格搜索的主要缺点是它对所有可能的组合进行穷举搜索,这可能非常耗时。生成良好组合的常见方法之一是 随机搜索,它通过选择和测试随机组合的超参数来加速过程。
对我们特别有意义的一个更好选择是在进行网格搜索时,利用遗传算法来寻找在预定义网格中超参数的最佳组合。这种方法比原始的全面网格搜索在更短时间内找到最佳组合的潜力更大。
虽然scikit-learn库支持网格搜索和随机搜索,但sklearn-deap提供了一个遗传算法驱动的网格搜索选项。这个小型库基于 DEAP 遗传算法的能力,并结合了scikit-learn现有的功能。在撰写本书时,这个库与scikit-learn的最新版本不兼容,因此我们在第八章的文件中包含了一个稍作修改的版本,并将使用该版本。
在接下来的章节中,我们将比较两种网格搜索方法——全面搜索和遗传算法驱动的搜索。但首先,我们将快速了解一下我们将在实验中使用的数据集——UCI 葡萄酒数据集。
葡萄酒数据集
一个常用的数据集来自UCI 机器学习库(archive.ics.uci.edu/),葡萄酒数据集(archive.ics.uci.edu/ml/datasets/Wine)包含对 178 种在意大利同一地区种植的葡萄酒进行的化学分析结果。这些葡萄酒被分为三种类型之一。
化学分析由 13 个不同的测量组成,表示每种葡萄酒中以下成分的含量:
-
酒精
-
苹果酸
-
灰分
-
灰分的碱度
-
镁
-
总酚
-
类黄酮
-
非类黄酮酚
-
原花青素
-
色度
-
色调
-
稀释葡萄酒的 OD280/OD315
-
脯氨酸
数据集的2到14列包含前述测量值,而分类结果——即葡萄酒类型本身(1、2或3)——则位于第一列。
接下来,让我们看看我们选择的分类器,用于对这个数据集进行分类。
自适应提升分类器
自适应提升算法,简称AdaBoost,是一种强大的机器学习模型,通过加权求和结合多个简单学习算法(弱学习器)的输出。AdaBoost 在学习过程中逐步添加弱学习器实例,每个实例都会调整以改进先前分类错误的输入。
scikit-learn库实现的此模型——AdaBoost 分类器(scikit-learn.org/stable/modules/generated/sklearn.ensemble.AdaBoostClassifier.html)——使用了多个超参数,其中一些如下:
| 名称 | 类型 | 描述 | 默认值 |
|---|---|---|---|
n_estimators |
整数类型 | 最大估算器数量 | 50 |
learning_rate |
浮动类型 | 每次提升迭代中应用于每个分类器的权重;较高的学习率增加每个分类器的贡献 | 1.0 |
algorithm |
枚举类型 | 使用的提升算法:{'SAMME' , 'SAMME.R'} |
'SAMME.R' |
表 8.1:超参数及其详细信息
有趣的是,这三个超参数各自具有不同的类型——一个是整数类型,一个是浮动类型,一个是枚举(或分类)类型。稍后我们将探讨每种调优方法如何处理这些不同类型的参数。我们将从两种网格搜索形式开始,下一节将描述这两种形式。
使用传统的与遗传网格搜索相比,调整超参数
为了封装通过网格搜索调优 AdaBoost 分类器的超参数,我们创建了一个名为 HyperparameterTuningGrid 的 Python 类,专门用于 Wine 数据集。此类位于 01_hyperparameter_tuning_grid.py 文件中,具体位置为:
该类的主要部分如下所示:
-
类的 init() 方法初始化葡萄酒数据集、AdaBoost 分类器、k 折交叉验证指标和网格参数:
self.initWineDataset() self.initClassifier() self.initKfold() self.initGridParams() -
initGridParams() 方法通过设置前一节中提到的三个超参数的测试值来初始化网格搜索:
-
n_estimators 参数在 10 个值之间进行了测试,这些值在 10 和 100 之间均匀分布。
-
learning_rate 参数在 100 个值之间进行了测试,这些值在 0.1 (10^−2) 和 1 (10⁰) 之间对数均匀分布。
-
algorithm 参数的两种可能值,'SAMME' 和 'SAMME.R',都进行了测试。
此设置覆盖了 200 种不同的网格参数组合(10×10×2):
self.gridParams = { 'n_estimators': [10, 20, 30, 40, 50, 60, 70, 80, 90, 100], 'learning_rate': np.logspace(-2, 0, num=10, base=10), 'algorithm': ['SAMME', 'SAMME.R'], } -
-
getDefaultAccuracy() 方法使用 '****准确度' 指标的均值评估分类器在其默认超参数值下的准确度:
cv_results = model_selection.cross_val_score( self.classifier, self.X, self.y, cv=self.kfold, scoring='accuracy') return cv_results.mean() -
gridTest() 方法在我们之前定义的测试超参数值集合上执行传统网格搜索。最优的参数组合是基于 k 折交叉验证的平均 '****准确度' 指标来确定的:
gridSearch = GridSearchCV( estimator=self.classifier, param_grid=self.gridParams, cv=self.kfold, scoring='accuracy') gridSearch.fit(self.X, self.y) -
geneticGridTest() 方法执行基于遗传算法的网格搜索。它使用 sklearn-deap 库的 EvolutionaryAlgorithmSearchCV() 方法,该方法的调用方式与传统网格搜索非常相似。我们所需要做的只是添加一些遗传算法参数——种群大小、变异概率、比赛大小和代数:
gridSearch = EvolutionaryAlgorithmSearchCV( estimator=self.classifier, params=self.gridParams, cv=self.kfold, scoring='accuracy', verbose=True, population_size=20, gene_mutation_prob=0.50, tournament_size=2, generations_number=5) gridSearch.fit(self.X, self.y) -
最后,类的main()方法首先评估分类器使用默认超参数值时的性能。然后,它进行常规的全面网格搜索,接着进行基于基因算法的网格搜索,同时记录每次搜索的时间。
运行该类的主方法的结果将在下一小节中描述。
测试分类器的默认性能
运行结果表明,使用默认参数值n_estimators = 50、learning_rate = 1.0和algorithm = 'SAMME.R'时,分类器的准确率约为 66.4%:
Default Classifier Hyperparameter values:
{'algorithm': 'SAMME.R', 'base_estimator': 'deprecated', 'estimator': None, 'learning_rate': 1.0, 'n_estimators': 50, 'random_state': 42}
score with default values = 0.6636507936507937
这不是一个特别好的准确率。希望通过网格搜索可以通过找到更好的超参数组合来改进这个结果。
运行常规的网格搜索
接下来执行常规的全面网格搜索,覆盖所有 200 种可能的组合。搜索结果表明,在这个网格内,最佳组合是n_estimators = 50、learning_rate ≈ 0.5995和algorithm = 'SAMME.R'。
使用这些值时,我们获得的分类准确率约为 92.7%,这是对原始 66.4%的大幅改进。搜索的运行时间大约是 131 秒,使用的是一台相对较旧的计算机:
performing grid search...
best parameters: {'algorithm': 'SAMME.R', 'learning_rate': 0.5994842503189409, 'n_estimators': 50}
best score: 0.9266666666666667
Time Elapsed = 131.01380705833435
接下来是基于基因算法的网格搜索。它能匹配这些结果吗?让我们来看看。
运行基于基因算法的网格搜索
运行的最后部分描述了基于基因算法的网格搜索,它与相同的网格参数一起执行。搜索的冗长输出从一个稍显晦涩的打印输出开始:
performing Genetic grid search...
Types [1, 2, 1] and maxint [9, 9, 1] detected
该打印输出描述了我们正在搜索的网格——一个包含 10 个整数(n_estimators值)的列表,一个包含 10 个元素(learning_rate值)的 ndarray,以及一个包含两个字符串(algorithm值)的列表——如下所示:
-
Types [1, 2, 1]表示[list, ndarray, list]的网格类型
-
maxint [9, 9, 1]对应于[10, 10, 2]的列表/数组大小
下一行打印的是可能的网格组合的总数(10×10×2):
--- Evolve in 200 possible combinations ---
剩余的打印输出看起来非常熟悉,因为它使用了我们一直在使用的基于 DEAP 的基因算法工具,详细描述了进化代的过程,并为每一代打印统计信息:
gen nevals avg min max std
0 20 0.708146 0.117978 0.910112 0.265811
1 13 0.870787 0.662921 0.910112 0.0701235
2 10 0.857865 0.662921 0.91573 0.0735955
3 12 0.87809 0.679775 0.904494 0.0473746
4 12 0.878933 0.662921 0.910112 0.0524153
5 7 0.864045 0.162921 0.926966 0.161174
在过程结束时,打印出最佳组合、得分值和所用时间:
Best individual is: {'n_estimators': 50, 'learning_rate': 0.5994842503189409, 'algorithm': 'SAMME.R'}
with fitness: 0.9269662921348315
Time Elapsed = 21.147947072982788
这些结果表明,基于基因算法的网格搜索能够在较短时间内找到与全面搜索相同的最佳结果。
请注意,这是一个运行非常快的简单示例。在实际情况中,我们通常会遇到大型数据集、复杂模型和庞大的超参数网格。在这些情况下,执行全面的网格搜索可能需要极长的时间,而基于基因算法的网格搜索在合理的时间内有可能获得不错的结果。
但是,所有网格搜索,无论是否由遗传算法驱动,都仅限于由网格定义的超参数值子集。如果我们希望在不受预定义值子集限制的情况下搜索网格外的内容呢?下节将描述一个可能的解决方案。
使用直接遗传方法调优超参数
除了提供高效的网格搜索选项外,遗传算法还可以直接搜索整个参数空间,正如我们在本书中用于搜索许多问题的输入空间一样。每个超参数可以表示为一个参与搜索的变量,染色体可以是所有这些变量的组合。
由于超参数可能有不同的类型——例如,我们的 AdaBoost 分类器中的 float、int 和枚举类型——我们可能希望对它们进行不同的编码,然后将遗传操作定义为适应每种类型的独立操作符的组合。然而,我们也可以使用一种懒惰的方法,将它们都作为浮动参数来简化算法的实现,正如我们接下来将看到的那样。
超参数表示
在第六章,《优化连续函数》中,我们使用遗传算法优化了实值参数的函数。这些参数被表示为一个浮动数字列表:[1.23, 7.2134, -25.309]。
因此,我们使用的遗传操作符是专门为处理浮动点数字列表而设计的。
为了调整这种方法,使其能够调优超参数,我们将把每个超参数表示为一个浮动点数,而不管其实际类型是什么。为了使其有效,我们需要找到一种方法将每个参数转换为浮动点数,并从浮动点数转换回其原始表示。我们将实现以下转换:
-
n_estimators,最初是一个整数,将表示为一个特定范围内的浮动值;例如,[1, 100]。为了将浮动值转换回整数,我们将使用 Python 的round()函数,它会将值四舍五入为最接近的整数。
-
learning_rate 已经是一个浮动点数,因此无需转换。它将绑定在[0.01, 1.0]范围内。
-
algorithm 可以有两个值,'SAMME' 或 'SAMME.R',并将由一个位于[0, 1]范围内的浮动数表示。为了转换该浮动值,我们将其四舍五入为最接近的整数——0 或 1。然后,我们将0替换为'SAMME',将1替换为'SAMME.R'。
这些转换将由两个 Python 文件执行,接下来的小节中将描述这两个文件。
评估分类器准确度
我们从一个 Python 类开始,该类封装了分类器的准确度评估,称为HyperparameterTuningGenetic。该类可以在hyperparameter_tuning_genetic_test.py文件中找到,该文件位于
该类的主要功能如下所示:
-
该类的convertParam()方法接受一个名为params的列表,包含表示超参数的浮动值,并将其转换为实际值(如前一小节所讨论):
n_estimators = round(params[0]) learning_rate = params[1] algorithm = ['SAMME', 'SAMME.R'][round(params[2])] -
getAccuracy()方法接受一个浮动数字的列表,表示超参数值,使用convertParam()方法将其转化为实际值,并用这些值初始化 AdaBoost 分类器:
n_estimators, learning_rate, algorithm = \ self.convertParams(params) self.classifier = AdaBoostClassifier( n_estimators=n_estimators, learning_rate=learning_rate, algorithm=algorithm) -
然后,它通过我们为葡萄酒数据集创建的 k 折交叉验证代码来找到分类器的准确度:
cv_results = model_selection.cross_val_score( self.classifier, self.X, self.y, cv=self.kfold, scoring='accuracy') return cv_results.mean()
该类被实现超参数调优遗传算法的程序所使用,具体内容将在下一节中描述。
使用遗传算法调整超参数
基于遗传算法的最佳超参数值搜索由 Python 程序02_hyperparameter_tuning_genetic.py实现,该程序位于
以下步骤描述了该程序的主要部分:
-
我们首先为表示超参数的每个浮动值设置下界和上界,如前一小节所述——[1, 100]用于n_estimators,[0.01, 1]用于learning_rate,[0, 1]用于algorithm:
# [n_estimators, learning_rate, algorithm]: BOUNDS_LOW = [ 1, 0.01, 0] BOUNDS_HIGH = [100, 1.00, 1] -
然后,我们创建了一个HyperparameterTuningGenetic类的实例,这将允许我们测试不同的超参数组合:
test = HyperparameterTuningGenetic(RANDOM_SEED) -
由于我们的目标是最大化分类器的准确率,我们定义了一个单一目标——最大化适应度策略:
creator.create("FitnessMax", base.Fitness, weights=(1.0,)) -
现在进入一个特别有趣的部分——由于解的表示是一个浮动值列表,每个值的范围不同,我们使用以下循环遍历所有的下界和上界值对。对于每个超参数,我们创建一个单独的工具箱操作符,用来在适当的范围内生成随机浮动值:
for i in range(NUM_OF_PARAMS): # "hyperparameter_0", "hyperparameter_1", ... toolbox.register("hyperparameter_" + str(i), random.uniform, BOUNDS_LOW[i], BOUNDS_HIGH[i]) -
然后,我们创建了超参数元组,包含我们刚刚为每个超参数创建的具体浮动数字生成器:
hyperparameters = () for i in range(NUM_OF_PARAMS): hyperparameters = hyperparameters + \ (toolbox.__getattribute__("hyperparameter_" + str(i)),) -
现在,我们可以使用这个超参数元组,结合 DEAP 内置的 initCycle() 操作符,创建一个新的 individualCreator 操作符,该操作符通过随机生成的超参数值的组合填充一个个体实例:
toolbox.register("individualCreator", tools.initCycle, creator.Individual, hyperparameters, n=1) -
然后,我们指示遗传算法使用 HyperparameterTuningGenetic 实例的 getAccuracy() 方法进行适应度评估。作为提醒,getAccuracy() 方法(我们在前一小节中描述过)将给定的个体——一个包含三个浮点数的列表——转换回它们所表示的分类器超参数值,用这些值训练分类器,并通过 k 折交叉验证评估其准确性:
def classificationAccuracy(individual): return test.getAccuracy(individual), toolbox.register("evaluate", classificationAccuracy) -
现在,我们需要定义遗传操作符。对于 selection 操作符,我们使用常见的锦标赛选择,锦标赛大小为 2,我们选择专门为有界浮点列表染色体设计的 crossover 和 mutation 操作符,并为它们提供我们为每个超参数定义的边界:
toolbox.register("select", tools.selTournament, tournsize=2) toolbox.register("mate", tools.cxSimulatedBinaryBounded, low=BOUNDS_LOW, up=BOUNDS_HIGH, eta=CROWDING_FACTOR) toolbox.register("mutate", tools.mutPolynomialBounded, low=BOUNDS_LOW, up=BOUNDS_HIGH, eta=CROWDING_FACTOR, indpb=1.0 / NUM_OF_PARAMS) -
此外,我们继续使用精英策略,即 HOF 成员——当前最佳个体——始终不受影响地传递到下一代:
population, logbook = elitism.eaSimpleWithElitism( population, toolbox, cxpb=P_CROSSOVER, mutpb=P_MUTATION, ngen=MAX_GENERATIONS, stats=stats, halloffame=hof, verbose=True)
通过用一个种群大小为 30 的算法运行五代,我们得到了以下结果:
gen nevals max avg
0 30 0.927143 0.831439
1 22 0.93254 0.902741
2 23 0.93254 0.907847
3 25 0.943651 0.916566
4 24 0.943651 0.921106
5 24 0.943651 0.921751
- Best solution is:
params = 'n_estimators'= 30, 'learning_rate'=0.613, 'algorithm'=SAMME.R
Accuracy = n_estimators = 30, learning_rate = 0.613, and algorithm = 'SAMME.R'.
The classification accuracy that we achieved with these values is about 94.4%—a worthy improvement over the accuracy we achieved with the grid search. Interestingly, the best value that was found for `learning_rate` is just outside the grid values we searched on.
Dedicated libraries
In recent years, several genetic-algorithm-based libraries have been developed that are dedicated to optimizing machine learning model development. One of them is `sklearn-genetic-opt` ([`sklearn-genetic-opt.readthedocs.io/en/stable/index.html`](https://sklearn-genetic-opt.readthedocs.io/en/stable/index.html)); it supports both hyperparameters tuning and feature selection. Another more elaborate library is `TPOT`([`epistasislab.github.io/tpot/`](https://epistasislab.github.io/tpot/)); this library provides optimization for the end-to-end machine learning development process, also called the **pipeline**. You are encouraged to try out these libraries in your own projects.
Summary
In this chapter, you were introduced to the concept of hyperparameter tuning in machine learning. After getting acquainted with the Wine dataset and the AdaBoost classifier, both of which we used for testing throughout this chapter, you were presented with the hyperparameter tuning methods of an exhaustive grid search and its genetic-algorithm-driven counterpart. These two methods were then compared using our test scenario. Finally, we tried out a direct genetic algorithm approach, where all the hyperparameters were represented as float values. This approach allowed us to improve the results of the grid search.
In the next chapter, we will look into the fascinating machine learning models of **neural networks** and **deep learning** and apply genetic algorithms to improve their performance.
Further reading
For more information on the topics that were covered in this chapter, please refer to the following resources:
* Cross-validation and Parameter Tuning, from the book *Mastering Predictive Analytics with scikit-learn and TensorFlow*, Alan Fontaine, September 2018:
* [`subscription.packtpub.com/book/big_data_and_business_intelligence/9781789617740/2/ch02lvl1sec16/introduction-to-hyperparameter-tuning`](https://subscription.packtpub.com/book/big_data_and_business_intelligence/9781789617740/2/ch02lvl1sec16/introduction-to-hyperparameter-tuning)
* *sklearn-deap* at GitHub: [`github.com/rsteca/sklearn-deap`](https://github.com/rsteca/sklearn-deap)
* *Scikit-learn* AdaBoost Classifier: [`scikit-learn.org/stable/modules/generated/sklearn.ensemble.AdaBoostClassifier.html`](https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.AdaBoostClassifier.html)
* *UCI Machine Learning* *Repository*: [`archive.ics.uci.edu/`](https://archive.ics.uci.edu/)
第九章:深度学习网络的架构优化
本章介绍了如何通过优化人工神经网络(ANN)模型的网络架构,利用遗传算法来提高这些模型的性能。我们将首先简要介绍神经网络(NNs)和深度学习(DL)。在介绍了鸢尾花数据集和多层感知器(MLP)分类器后,我们将展示如何通过基于遗传算法的解决方案来进行网络架构优化。随后,我们将扩展此方法,将网络架构优化与模型超参数调优相结合,二者将通过基于遗传算法的解决方案共同完成。
本章将涉及以下主题:
-
理解人工神经网络和深度学习的基本概念
-
通过网络架构优化来提升深度学习分类器的性能
-
通过将网络架构优化与超参数调优相结合,进一步增强深度学习分类器的性能
本章将从人工神经网络的概述开始。如果你是经验丰富的数据科学家,可以跳过介绍部分。
技术要求
本章将使用 Python 3,并配合以下支持库:
-
deap
-
numpy
-
scikit-learn
重要提示
如果你使用我们提供的requirements.txt文件(见第三章),这些库已经包含在你的环境中。
此外,我们将使用 UCI 鸢尾花数据集(archive.ics.uci.edu/ml/datasets/Iris)。
本章将使用的程序可以在本书的 GitHub 仓库中找到,链接如下:
查看以下视频,查看代码实际操作:packt.link/OEBOd
人工神经网络和深度学习
受人脑结构的启发,神经网络是机器学习(ML)中最常用的模型之一。这些网络的基本构建块是节点或神经元,它们基于生物神经元细胞,如下图所示:

图 9.1:生物神经元模型
来源:simple.wikipedia.org/wiki/Neuron#/media/File:Neuron.svg 由 Dhp1080 提供
神经元细胞的树突,在前图左侧围绕细胞体,用作来自多个相似细胞的输入,而从细胞体出来的长轴突则作为输出,可以通过其末端连接到多个其他细胞。
这种结构通过一个人工模型——感知器来模拟,如下所示:

图 9.2:人工神经元模型——感知器
感知器通过将每个输入值与一定的权重相乘来计算输出;结果会累加,然后加上一个偏置值。一个非线性的激活函数随后将结果映射到输出。这种功能模仿了生物神经元的运作,当输入的加权和超过某个阈值时,神经元会“激发”(从其输出端发送一系列脉冲)。
如果我们调整感知器的权重和偏置值,使其将某些输入映射到期望的输出水平,则可以使用感知器模型进行简单的分类和回归任务。然而,通过将多个感知器单元连接成一个叫做 MLP 的结构,可以构建一个功能更强大的模型,下一小节将对其进行描述。
MLP
MLP 通过使用多个节点扩展了感知器的概念,每个节点实现一个感知器。MLP 中的节点按层排列,每一层与下一层相连接。MLP 的基本结构如下图所示:

图 9.3:MLP 的基本结构
MLP 由三个主要部分组成:
-
输入层:接收输入值,并将每个输入值与下一个层中的每个神经元相连接。
-
输出层:传递 MLP 计算的结果。当 MLP 用作分类器时,每个输出表示一个类别。当 MLP 用于回归时,将只有一个输出节点,产生一个连续值。
-
隐藏层:提供该模型的真正力量和复杂性。尽管前面的图示只显示了两个隐藏层,但可以有多个隐藏层,每个隐藏层的大小可以是任意的,这些隐藏层位于输入层和输出层之间。随着隐藏层数量的增加,网络变得更深,能够执行越来越复杂的非线性映射,连接输入和输出。
训练这个模型涉及调整每个节点的权重和偏置值。通常,这通过一类被称为反向传播的算法来实现。反向传播的基本原理是通过将输出误差从输出层向内传播到 MLP 模型的各个层,最小化实际输出与期望输出之间的误差。该过程从定义一个成本(或“损失”)函数开始,通常是预测输出与实际目标值之间差异的度量。通过调整各个节点的权重和偏置,使得那些对误差贡献最大节点的调整最大。通过迭代减少成本函数,算法逐步优化模型参数,提高性能。
多年来,反向传播算法的计算限制使得 MLP 的隐藏层数不超过两层或三层,直到新的发展极大地改变了这一局面。相关内容将在下一节中详细说明。
深度学习和卷积神经网络
近年来,反向传播算法取得了突破,允许在单个网络中使用大量的隐藏层。在这些深度神经网络(DNNs)中,每一层可以解释前一层节点所学到的多个更简单的抽象概念,并产生更高层次的概念。例如,在实现人脸识别任务时,第一层将处理图像的像素并学习检测不同方向的边缘。下一层可能将这些边缘组合成线条、角点等,直到某一层能够检测面部特征,如鼻子和嘴唇,最后,一层将这些特征结合成完整的“面部”概念。
进一步的发展催生了卷积神经网络(CNNs)的概念。这些结构通过对相邻输入与远离的输入进行不同处理,能够减少处理二维信息(如图像)的深度神经网络(DNNs)中的节点数量。因此,这些模型在图像和视频处理任务中尤为成功。除了与多层感知器(MLP)中的隐藏层类似的全连接层外,这些网络还使用池化(下采样)层,池化层将前面层的神经元输出进行汇总,以及卷积层,卷积层通过在输入图像上有效滑动滤波器来检测特定特征,例如各种方向的边缘。
使用scikit-learn库和一个简单的数据集进行训练。然而,所使用的原理仍适用于更复杂的网络和数据集。
在下一节中,我们将探讨如何使用遗传算法优化多层感知器(MLP)的架构。
优化深度学习分类器的架构
在为给定的机器学习任务创建神经网络模型时,一个关键的设计决策是网络架构的配置。对于多层感知机(MLP)而言,输入层和输出层的节点数由问题的特征决定。因此,待决策的部分是隐藏层——有多少层,每一层有多少个节点。可以使用一些经验法则来做出这些决策,但在许多情况下,识别最佳选择可能会变成一个繁琐的试错过程。
处理网络架构参数的一种方法是将它们视为模型的超参数,因为它们需要在训练之前确定,并且因此会影响训练结果。在本节中,我们将应用这种方法,并使用遗传算法来搜索最佳的隐藏层组合,类似于我们在上一章选择最佳超参数值的方式。我们从我们想要解决的任务开始——鸢尾花 分类。
鸢尾花数据集
可能是研究得最透彻的数据集,鸢尾花数据集(archive.ics.uci.edu/ml/datasets/Iris)包含了三种鸢尾花(鸢尾花、弗吉尼亚鸢尾花、和变色鸢尾花)的萼片和花瓣的测量数据,这些数据由生物学家在 1936 年收集。
数据集包含来自三种物种的每种 50 个样本,并由以下四个特征组成:
-
萼片长度 (cm)
-
萼片宽度 (cm)
-
花瓣长度 (cm)
-
花瓣宽度 (cm)
该数据集可以通过scikit-learn库直接获得,并可以通过如下方式初始化:
from sklearn import datasets
data = datasets.load_iris()
X = data['data']
y = data['target']
在我们的实验中,我们将使用多层感知机(MLP)分类器与此数据集结合,并利用遗传算法的力量来寻找最佳的网络架构——隐藏层的数量和每层节点的数量——以获得最佳的分类准确率。
由于我们使用遗传算法的方法,首先需要做的是找到一种方法,通过染色体表示这种架构,如下一个小节所述。
表示隐藏层配置
由于多层感知器(MLP)的结构由隐藏层配置决定,让我们来探讨如何在我们的解决方案中表示这一配置。sklearn MLP 的隐藏层配置(scikit-learn.org/stable/modules/neural_networks_supervised.html)通过 hidden_layer_sizes 元组传递,这个元组作为参数传递给模型的构造函数。默认情况下,这个元组的值为 (100,),意味着只有一个包含 100 个节点的隐藏层。如果我们想要将 MLP 配置为三个每个包含 20 个节点的隐藏层,则该参数的值应为 (20, 20, 20)。在我们实现基于遗传算法的优化器来调整隐藏层配置之前,我们需要定义一个可以转换为这种模式的染色体。
为了实现这一目标,我们需要设计一种染色体,既能够表示层的数量,又能表示每层的节点数。一种可行的方案是使用一个可变长度的染色体,该染色体可以直接转换为用作模型 hidden_layer_sizes 参数的可变长度元组;然而,这种方法需要定制且可能繁琐的遗传操作符。为了能够使用我们的标准遗传操作符,我们将使用一个固定长度的表示法。采用这种方法时,最大层数是预先确定的,所有层始终被表示,但不一定会在解决方案中得到体现。例如,如果我们决定将网络限制为四个隐藏层,染色体将如下所示:
[n 1, n 2, n 3, n 4]
这里,n i 表示第 i 层的节点数。然而,为了控制网络中实际的隐藏层数量,这些值中的一些可能是零或负数。这样的值意味着不会再添加更多的层到网络中。以下示例展示了这种方法:
-
染色体 [10, 20, -5, 15] 被转换为元组 (10, 20),因为 -5 终止了层的计数
-
染色体 [10, 0, -5, 15] 被转换为元组 (10, ),因为 0 终止了层的计数
-
染色体 [10, 20, 5, -15] 被转换为元组 (10, 20, 5),因为 -15 终止了层的计数
-
染色体 [10, 20, 5, 15] 被转换为元组 (10, 20, 5, 15)
为了保证至少有一个隐藏层,我们可以确保第一个参数始终大于零。其他参数可以围绕零分布变化,以便我们能够控制它们作为终止参数的可能性。
此外,尽管该染色体由整数构成,我们选择改用浮动数字,就像我们在上一章中对各种类型的变量所做的那样。使用浮动数字的列表非常方便,因为它允许我们使用现有的遗传算子,同时能够轻松扩展染色体,以便包含其他不同类型的参数,稍后我们会这样做。浮动数字可以通过round()函数转换回整数。以下是这种通用方法的几个示例:
-
染色体[9.35, 10.71, -2.51, 17.99]被转换为元组(9, 11)
-
染色体[9.35, 10.71, 2.51, -17.99]被转换为元组(9, 11, 3)
为了评估给定的表示架构的染色体,我们需要将其转换回层的元组,创建一个实现这些层的 MLP 分类器,训练它并进行评估。我们将在下一小节中学习如何做到这一点。
评估分类器的准确性
让我们从一个 Python 类开始,该类封装了 Iris 数据集的 MLP 分类器准确性评估。该类被称为MlpLayersTest,可以在以下链接的mlp_layers_test.py文件中找到:
该类的主要功能如下所示:
-
该类的convertParam()方法接收一个名为params的列表。实际上,这就是我们在上一小节中描述的染色体,它包含表示最多四个隐藏层的浮动值。该方法将这些浮动值的列表转换为hidden_layer_sizes元组:
if round(params[1]) <= 0: hiddenLayerSizes = round(params[0]), elif round(params[2]) <= 0: hiddenLayerSizes = (round(params[0]), round(params[1])) elif round(params[3]) <= 0: hiddenLayerSizes = (round(params[0]), round(params[1]), round(params[2])) else: hiddenLayerSizes = (round(params[0]), round(params[1]), round(params[2]), round(params[3])) -
getAccuracy()方法接受表示隐藏层配置的params列表,使用convertParam()方法将其转换为hidden_layer_sizes元组,并使用该元组初始化 MLP 分类器:
hiddenLayerSizes = self.convertParams(params) self.classifier = MLPClassifier( hidden_layer_sizes=hiddenLayerSizes)然后,它使用与我们在 第八章中为葡萄酒数据集创建的相同k 折交叉验证计算来找到分类器的准确性,机器学习模型的超参数调优:
cv_results = model_selection.cross_val_score(self.classifier, self.X, self.y, cv=self.kfold, scoring='accuracy') return cv_results.mean()
MlpLayersTest类被基于遗传算法的优化器所使用。我们将在下一节中解释这一部分。
使用遗传算法优化 MLP 架构
现在我们有了一种表示用于分类 Iris 花卉数据集的 MLP 架构配置的方法,并且有了一种确定每个配置的 MLP 准确性的方法,我们可以继续并创建一个基于遗传算法的优化器,来搜索最佳的配置——隐藏层的数量(在我们的例子中最多为 4 层)以及每层的节点数量——以获得最佳的准确性。这个解决方案通过位于以下链接的01_optimize_mlp_layers.py Python 程序实现:
以下步骤描述了该程序的主要部分:
-
我们首先为表示隐藏层的每个浮动值设置上下边界。第一个隐藏层的范围为[5, 15],而其余的层从逐渐增大的负值开始,这增加了它们终止层数的几率:
# [hidden_layer_layer_1_size, hidden_layer_2_size # hidden_layer_3_size, hidden_layer_4_size] BOUNDS_LOW = [ 5, -5, -10, -20] BOUNDS_HIGH = [15, 10, 10, 10] -
然后,我们创建一个MlpLayersTest类的实例,这将允许我们测试不同的隐藏层架构组合:
test = mlp_layers_test.MlpLayersTest(RANDOM_SEED) -
由于我们的目标是最大化分类器的准确性,我们定义了一个单一的目标,即最大化适应度策略:
creator.create("FitnessMax", base.Fitness, weights=(1.0,)) -
现在,我们采用与上一章相同的方法——由于解决方案由一个浮动数值列表表示,每个数值的范围不同,我们使用以下循环遍历所有的下限和上限值对,并且对于每个范围,我们创建一个单独的toolbox操作符layer_size_attribute,该操作符将用于在适当的范围内生成随机浮动数值:
for i in range(NUM_OF_PARAMS): toolbox.register("layer_size_attribute_" + str(i), random.uniform, BOUNDS_LOW[i], BOUNDS_HIGH[i]) -
然后,我们创建一个layer_size_attributes元组,其中包含我们为每个隐藏层刚刚创建的单独浮动数值生成器:
layer_size_attributes = () for i in range(NUM_OF_PARAMS): layer_size_attributes = layer_size_attributes + \ (toolbox.__getattribute__("layer_size_attribute_" + \ str(i)),) -
现在,我们可以将这个layer_size_attributes元组与 DEAP 内置的initCycle()操作符结合使用,来创建一个新的individualCreator操作符,该操作符将通过随机生成的隐藏层大小值的组合填充一个个体实例:
toolbox.register("individualCreator", tools.initCycle, creator.Individual, layer_size_attributes, n=1) -
然后,我们指示遗传算法使用MlpLayersTest实例的getAccuracy()方法进行适应度评估。提醒一下,getAccuracy()方法(我们在上一小节中描述过)将给定个体——一个包含四个浮动数值的列表——转换为一个隐藏层大小的元组。这些元组将用于配置 MLP 分类器。然后,我们训练分类器并使用 k 折交叉验证评估其准确性:
def classificationAccuracy(individual): return test.getAccuracy(individual), toolbox.register("evaluate", classificationAccuracy) -
至于遗传操作符,我们重复了上一章的配置。对于选择操作符,我们使用常规的锦标赛选择,锦标赛大小为 2,选择适用于有界浮动列表染色体的交叉和变异操作符,并为每个隐藏层提供我们定义的边界:
toolbox.register("select", tools.selTournament, tournsize=2) toolbox.register("mate", tools.cxSimulatedBinaryBounded, low=BOUNDS_LOW, up=BOUNDS_HIGH, eta=CROWDING_FACTOR) toolbox.register("mutate", tools.mutPolynomialBounded, low=BOUNDS_LOW, up=BOUNDS_HIGH, eta=CROWDING_FACTOR, indpb=1.0 / NUM_OF_PARAMS) -
此外,我们继续使用精英方法,其中名人堂(HOF)成员——当前最佳个体——总是被不加修改地传递到下一代:
population, logbook = elitism.eaSimpleWithElitism(population, toolbox, cxpb=P_CROSSOVER, mutpb=P_MUTATION, ngen=MAX_GENERATIONS, stats=stats, halloffame=hof, verbose=True)
当我们用 20 个个体运行算法 10 代时,得到的结果如下:
gen nevals max avg
0 20 0.666667 0.416333
1 17 0.693333 0.487
2 15 0.76 0.537333
3 14 0.76 0.550667
4 17 0.76 0.568333
5 17 0.76 0.653667
6 14 0.76 0.589333
7 15 0.76 0.618
8 16 0.866667 0.616667
9 16 0.866667 0.666333
10 16 0.866667 0.722667
- Best solution is: 'hidden_layer_sizes'=(15, 5, 8) , accuracy = 0.8666666666666666
之前的结果表明,在我们定义的范围内,找到的最佳组合是三个隐藏层,大小分别为 15、5 和 8。我们使用这些值所获得的分类准确率大约为 86.7%。
这个准确率似乎是对于当前问题的合理结果。然而,我们还有更多的工作可以做,以进一步提高其准确性。
将架构优化与超参数调优结合
在优化网络架构配置——即隐藏层参数——时,我们一直使用 MLP 分类器的默认(超)参数。然而,正如我们在上一章中所看到的,调优各种超参数有可能提高分类器的性能。我们能否将超参数调优纳入我们的优化中?如你所猜测的,答案是肯定的。但在此之前,让我们先来看一下我们希望优化的超参数。
scikit-learn实现的 MLP 分类器包含许多可调的超参数。为了展示,我们将集中在以下超参数上:
| 名称 | 类型 | 描述 | 默认值 |
|---|---|---|---|
| 激活函数 | 枚举类型 | 隐藏层的激活函数:{'identity', 'logistic', 'tanh', 'relu'} |
'relu' |
| 求解器 | 枚举类型 | 权重优化的求解器:{'lbfgs', 'sgd', 'adam'} |
'adam' |
| alpha | 浮动型 | L2 正则化项的强度 | 0.0001 |
| 学习率 | 枚举类型 | 权重更新的学习率计划: | 'constant' |
表 9.1:MLP 超参数
正如我们在上一章所看到的,基于浮动点的染色体表示使我们能够将各种类型的超参数结合到基于遗传算法的优化过程中。由于我们已经使用基于浮动点的染色体来表示隐藏层的配置,我们现在可以通过相应地扩展染色体,将其他超参数纳入优化过程中。让我们来看看我们如何做到这一点。
解的表示
对于现有的四个浮动值,表示我们的网络架构配置——
[n 1, n 2, n 3, n 4]——我们可以添加以下四个超参数:
-
activation可以有四个值之一:'tanh'、'relu'、'logistic'或'identity'。可以通过将其表示为[0, 3.99]范围内的浮点数来实现。为了将浮点值转换为上述值之一,我们需要对其应用floor()函数,这将得到 0、1、2 或 3。然后,我们将 0 替换为'tanh',将 1 替换为'relu',将 2 替换为'logistic',将 3 替换为'identity'。
-
solver可以有三个值之一:'sgd'、'adam'或'lbfgs'。与激活参数一样,它可以使用[0, 2.99]范围内的浮点数表示。
-
alpha已经是一个浮点数,因此无需转换。它将被限制在[0.0001, 2.0]的范围内。
-
learning_rate可以有三个值之一:'constant'、'invscaling'或'adaptive'。同样,我们可以使用[0, 2.99]范围内的浮点数来表示其值。
评估分类器的准确性
用于评估给定隐藏层和超参数组合的 MLP 分类器准确性的类叫做MlpHyperparametersTest,并包含在mlp_hyperparameters_test.py文件中,该文件位于以下链接:
这个类基于我们用于优化隐藏层配置的类MlpLayersTest,但做了一些修改。我们来看看这些修改:
-
convertParam()方法现在处理一个params列表,其中前四个条目(params[0]到params[3])代表隐藏层的大小,和之前一样,但另外,params[4]到params[7]代表我们为评估添加的四个超参数。因此,方法已通过以下代码行进行了扩展,允许它将其余给定的参数(params[4]到params[7])转换为相应的值,然后可以传递给 MLP 分类器:
activation = ['tanh', 'relu', 'logistic', 'identity'][floor(params[4])] solver = ['sgd', 'adam', 'lbfgs'][floor(params[5])] alpha = params[6] learning_rate = ['constant', 'invscaling', 'adaptive'][floor(params[7])] -
同样,getAccuracy()方法现在处理扩展后的params列表。它使用所有这些参数的转换值来配置 MLP 分类器,而不是仅仅配置隐藏层的设置:
hiddenLayerSizes, activation, solver, alpha, learning_rate = \ self.convertParams(params) self.classifier = MLPClassifier( random_state=self.randomSeed, hidden_layer_sizes=hiddenLayerSizes, activation=activation, solver=solver, alpha=alpha, learning_rate=learning_rate)
这个MlpHyperparametersTest类被基于遗传算法的优化器使用。我们将在下一节中讨论这个内容。
使用遗传算法优化 MLP 的组合配置
基于遗传算法的最佳隐藏层和超参数组合搜索由02_ptimize_mlp_hyperparameters.py Python 程序实现,该程序位于以下链接:
由于所有参数都使用统一的浮点数表示,这个程序与我们在前一节中用来优化网络架构的程序几乎相同。主要的区别在于BOUNDS_LOW和BOUNDS_HIGH列表的定义,它们包含了参数的范围。除了之前定义的四个范围(每个隐藏层一个),我们现在添加了另外四个范围,代表我们在本节中讨论的额外超参数:
# 'hidden_layer_sizes': first four values
# 'activation' : 0..3.99
# 'solver' : 0..2.99
# 'alpha' : 0.0001..2.0
# 'learning_rate' : 0..2.99
BOUNDS_LOW = [ 5, -5, -10, -20, 0, 0, 0.0001, 0]
BOUNDS_HIGH = [15, 10, 10, 10, 3.999, 2.999, 2.0, 2.999]
就这么简单——程序能够处理新增的参数而无需进一步修改。
运行此程序将产生以下结果:
gen nevals max avg
0 20 0.94 0.605667
1 15 0.94 0.667
2 16 0.94 0.848667
3 17 0.94 0.935
4 17 0.94 0.908667
5 15 0.94 0.936
6 15 0.94 0.889667
7 16 0.94 0.938333
8 17 0.946667 0.938333
9 13 0.946667 0.938667
10 15 0.946667 0.940667
- Best solution is:
'hidden_layer_sizes'=(7, 4, 6)
'activation'='tanh'
'solver'='lbfgs'
'alpha'=1.2786182334834102
'learning_rate'='constant'
=> accuracy = 0.9466666666666667
重要提示
请注意,由于操作系统之间的差异,当你在自己的系统上运行该程序时,可能会得到与此处展示的结果略有不同的输出。
前述结果表明,在我们定义的范围内,找到的最佳组合如下:
-
三个隐藏层,分别为 7、4 和 6 个节点。
-
'tanh'类型的激活函数参数——而不是默认的'relu'
-
'lbfgs'类型的求解器参数——而不是默认的'adam'
-
alpha值约为1.279——比默认值 0.0001 大得多
-
'constant'类型的learning_rate参数——与默认值相同
这种联合优化最终达到了约 94.7%的分类准确率——比之前的结果有了显著提升,而且使用的节点比之前更少。
总结
在本章中,你了解了人工神经网络(ANN)和深度学习(DL)的基本概念。熟悉了 Iris 数据集和 MLP 分类器后,我们介绍了网络架构优化的概念。接下来,我们演示了基于遗传算法的 MLP 分类器网络架构优化。最后,我们能够将网络架构优化与模型超参数调优结合起来,使用相同的遗传算法方法,从而进一步提升分类器的性能。
到目前为止,我们集中讨论了监督学习(SL)。在下一章中,我们将探讨将遗传算法应用于强化学习(RL),这是一个令人兴奋且快速发展的机器学习分支。
深入阅读
如需了解本章内容的更多信息,请参考以下资源:
-
Python 深度学习——第二版, Gianmario Spacagna, Daniel Slater, 等, 2019 年 1 月 16 日
-
使用 Python 的神经网络项目, James Loy, 2019 年 2 月 28 日
-
scikit-learn MLP 分类器:
-
scikit-learn.org/stable/modules/generated/sklearn.neural_network.MLPClassifier.html -
UCI 机器学习 数据集库:
archive.ics.uci.edu/
第十章:使用遗传算法进行强化学习
在本章中,我们将展示如何将遗传算法应用于强化学习——这一快速发展的机器学习分支,能够解决复杂的任务。我们将通过解决来自Gymnasium(前身为OpenAI Gym)工具包的两个基准环境来实现这一目标。我们将首先概述强化学习,随后简要介绍Gymnasium,这是一个可用于比较和开发强化学习算法的工具包,并描述其基于 Python 的接口。接下来,我们将探索两个 Gymnasium 环境,MountainCar和CartPole,并开发基于遗传算法的程序来解决它们所面临的挑战。
在本章中,我们将涵盖以下主题:
-
理解强化学习的基本概念
-
熟悉Gymnasium项目及其共享接口
-
使用遗传算法解决Gymnasium的MountainCar环境
-
使用遗传算法结合神经网络解决Gymnasium的CartPole环境
我们将通过概述强化学习的基本概念来开始本章。如果你是经验丰富的数据科学家,可以跳过这一介绍部分。
技术要求
在本章中,我们将使用 Python 3 及以下支持库:
-
deap
-
numpy
-
scikit-learn
-
gymnasium – 在本章中介绍
-
pygame – 在本章中介绍
重要提示
如果你使用我们提供的requirements.txt文件(参见第三章),这些库已经包含在你的环境中了。
本章将使用的Gymnasium环境是MountainCar-v0(gymnasium.farama.org/environments/classic_control/mountain_car/)和CartPole-v1(gymnasium.farama.org/environments/classic_control/cart_pole/)。
本章中使用的程序可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/tree/main/chapter_10。
查看以下视频,看看代码如何运行:packt.link/OEBOd。
强化学习
在前几章中,我们讨论了与机器学习相关的多个主题,重点介绍了监督学习任务。尽管监督学习非常重要,并且有许多现实生活中的应用,但目前看来,强化学习是机器学习中最令人兴奋和最有前景的分支。人们对这一领域的兴奋之情源自于强化学习能够处理的复杂且类似于日常生活的任务。2016 年 3 月,基于强化学习的AlphaGo系统成功战胜了被认为是过去十年最强围棋选手的选手,并且这一比赛得到了广泛的媒体报道。
虽然监督学习需要标注数据进行训练——换句话说,需要输入与匹配输出的对——但强化学习并不会立即给出对错反馈;相反,它提供了一个寻求长期、累积奖励的环境。这意味着,有时算法需要暂时后退一步,才能最终实现长期目标,正如我们将在本章的第一个例子中展示的那样。
强化学习任务的两个主要组件是环境和智能体,如下面的图示所示:

图 10.1: 强化学习表示为智能体与环境之间的互动
智能体代表了一种与环境互动的算法,它通过最大化累积奖励来尝试解决给定的问题。
智能体与环境之间发生的交换可以表示为一系列的步骤。在每一步中,环境会向智能体呈现一个特定的状态(s),也称为观察。智能体则执行一个动作(a)。环境会回应一个新的状态(s’),以及一个中间奖励值(R)。这一交换会一直重复,直到满足某个停止条件。智能体的目标是最大化沿途收集的奖励值的总和。
尽管这一表述非常简单,但它可以用来描述极其复杂的任务和情境,这也使得强化学习适用于广泛的应用场景,如博弈论、医疗保健、控制系统、供应链自动化和运筹学。
本章将再次展示遗传算法的多功能性,因为我们将利用它们来辅助强化学习任务。
遗传算法与强化学习
为了执行强化学习任务,已经开发了多种专用算法——如 Q-Learning、SARSA 和 DQN 等。然而,由于强化学习任务涉及最大化长期奖励,我们可以将它们视为优化问题。正如本书中所展示的,遗传算法可以用于解决各种类型的优化问题。因此,遗传算法也可以用于强化学习,并且有几种不同的方式——本章将演示其中的两种。在第一种情况下,我们基于遗传算法的解决方案将直接提供智能体的最佳动作序列。在第二种情况下,它将为提供这些动作的神经控制器提供最佳参数。
在我们开始将遗传算法应用于强化学习任务之前,让我们先了解将用于执行这些任务的工具包——Gymnasium。
Gymnasium
Gymnasium (gymnasium.farama.org/) —— 这是 OpenAI Gym 的一个分支和官方继任者 —— 是一个开源库,旨在提供对标准化强化学习任务集合的访问。它提供了一个工具包,用于比较和开发强化学习算法。
Gymnasium 是由一系列环境组成的集合,这些环境都呈现一个共同的接口,称为 env。这个接口将各种环境与智能体解耦,智能体可以以任何我们喜欢的方式实现——智能体唯一的要求是能够通过 env 接口与环境进行交互。这个内容将在下一小节中进行描述。
基本包 gymnasium 提供对多个环境的访问,可以通过以下方式进行安装:
pip install gymnasium
为了使我们能够渲染和动画化测试环境,还需要安装 PyGame 库。可以使用以下命令进行安装:
pip install pygame
还有一些其他的包可用,例如“Atari”、“Box2D”和“MuJoCo”,它们提供对多个多样化环境的访问。这些包有些具有系统依赖性,可能只适用于某些操作系统。更多信息请访问 github.com/Farama-Foundation/Gymnasium#installation。
下一小节将描述如何与 env 接口进行交互。
env 接口
要创建一个环境,我们需要使用 make() 方法并提供所需环境的名称,如下所示:
import gymnasium as gym
env = gym.reset() method, as shown in the following code snippet:
observation, info = env.observation 对象,描述环境的初始状态,以及一个字典 info,可能包含补充 observation 的辅助信息。observation 的内容依赖于环境。
与我们在上一节中描述的强化学习周期一致,与环境的持续互动包括发送一个 动作,然后接收一个 中间奖励 和一个新的 状态。这一过程通过 step() 方法实现,如下所示:
observation, reward, terminated, truncated, info = \
env.observation object, which describes the new state and the float reward value that represent the interim reward, this method returns the following values:
* **terminated**: A Boolean that turns **true** when the current run (also called *episode*) reaches the terminal state – for example, the agent lost a life, or successfully completed a task.
* **truncated**: A Boolean that can be used to end the episode prematurely before a terminal state is reached – for example, due to a time limit, or if the agent went out of bounds.
* **info**: A dictionary containing optional, additional information that may be useful for debugging. However, it should not be used by the agent for learning.
At any point in time, the environment can be rendered for visual presentation, as follows:
env.render_mode 可以在创建环境时进行设置。例如,设置为 "human" 会使环境在当前显示器或终端中持续渲染,而默认值 None 则不会进行渲染。
最后,可以关闭环境以调用任何必要的清理操作,如下所示:
env.close()
如果没有调用此方法,环境将在下一次 Python 执行 垃圾回收 进程(即识别并释放程序不再使用的内存)时自动关闭,或者当程序退出时关闭。
注意
有关 env 接口的详细信息,请参见 gymnasium.farama.org/api/env/。
与环境的完整交互周期将在下一节中演示,在那里我们将遇到第一个 Gymnasium 挑战——MountainCar 环境。
解决 MountainCar 环境
MountainCar-v0 环境模拟了一辆位于两座山丘之间的单维轨道上的汽车。模拟开始时,汽车被放置在两座山丘之间,如下图所示:

图 10.2:MountainCar 模拟——起点
目标是让汽车爬上更高的山丘——右侧的山丘——并最终触碰到旗帜:

图 10.3:MountainCar 模拟——汽车爬上右侧山丘
这个模拟设置的情景是汽车的引擎太弱,无法直接爬上更高的山丘。达到目标的唯一方法是让汽车前后行驶,直到积累足够的动能以供攀爬。爬上左侧山丘有助于实现这一目标,因为到达左侧山顶会使汽车反弹到右侧,以下截图展示了这一过程:

图 10.4:MountainCar 模拟——汽车从左侧山丘反弹
这个模拟是一个很好的例子,表明中间的损失(向左移动)可以帮助实现最终目标(完全向右移动)。
这个模拟中的预期 动作 值是一个整数,取以下三个值之一:
-
0: 向左推动
-
1: 不推动
-
2: 向右推动
observation 对象包含两个浮动值,描述了汽车的位置和速度,如下所示:
[-1.0260268, -0.03201975]
最后,reward值在每个时间步为-1,直到达到目标(位于位置 0.5)。如果在 200 步之前没有达到目标,模拟将会停止。
该环境的目标是尽可能快速地到达位于右侧山丘顶部的旗帜,因此,智能体在每个时间步上都会被扣除-1 的奖励。
关于MountainCar-v0环境的更多信息可以在这里找到:
gymnasium.farama.org/environments/classic_control/mountain_car/。
在我们的实现中,我们将尝试使用最少的步数来撞击旗帜,因为我们会从固定的起始位置应用一系列预选的动作。为了找到一个能够让小车爬上高山并撞击旗帜的动作序列,我们将设计一个基于遗传算法的解决方案。像往常一样,我们将首先定义这个挑战的候选解应如何表现。
解的表示
由于MountainCar是通过一系列动作来控制的,每个动作的值为 0(向左推动)、1(不推动)或 2(向右推动),并且在单个回合中最多可以有 200 个动作,因此表示候选解的一种显而易见的方法是使用长度为 200 的列表,列表中的值为 0、1 或 2。一个示例如下:
[0, 1, 2, 0, 0, 1, 2, 2, 1, ... , 0, 2, 1, 1]
列表中的值将用作控制小车的动作,并且希望能够把它驱动到旗帜。如果小车在少于 200 步的时间内到达了旗帜,列表中的最后几项将不会被使用。
接下来,我们需要确定如何评估这种形式的给定解。
评估解的质量
在评估给定解时,或者在比较两个解时,很明显,单独的奖励值可能无法提供足够的信息。根据当前奖励的定义,如果我们没有撞到旗帜,它的值将始终为-200。当我们比较两个没有撞到旗帜的候选解时,我们仍然希望知道哪一个更接近旗帜,并将其视为更好的解。因此,除了奖励值外,我们还将使用小车的最终位置来确定解的得分:
-
如果小车没有撞到旗帜,得分将是与旗帜的距离。因此,我们将寻找一个能够最小化得分的解。
-
如果小车撞到旗帜,基础得分将为零,从此基础上根据剩余未使用的步骤数扣除一个额外的值,使得得分为负。由于我们寻求最低的得分,这种安排将鼓励解通过尽可能少的动作撞击旗帜。
该评分评估过程由MountainCar类实现,下面的子章节中将对其进行详细探讨。
Python 问题表示
为了封装 MountainCar 挑战,我们创建了一个名为MountainCar的 Python 类。该类包含在mountain_car.py文件中,文件位于github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_10/mountain_car.py。
该类通过一个随机种子初始化,并提供以下方法:
-
getScore(actions):计算给定解决方案的得分,解决方案由动作列表表示。得分是通过启动一个MountainCar环境的回合并用提供的动作运行它来计算的,如果在少于 200 步的情况下击中目标,得分可能为负值。得分越低越好。
-
saveActions(actions):使用pickle(Python 的对象序列化和反序列化模块)将动作列表保存到文件。
-
replaySavedActions():反序列化最后保存的动作列表,并使用replay方法重放它。
-
replay(actions):使用“human”render_mode渲染环境,并重放给定的动作列表,展示给定的解决方案。
类的主要方法可以在找到解决方案、序列化并使用saveActions()方法保存后使用。主方法将初始化类并调用replaySavedActions()以渲染和动画展示最后保存的解决方案。
我们通常使用主方法来展示由遗传算法程序找到的最佳解决方案的动画。接下来的小节将详细探讨这一点。
遗传算法解决方案
为了使用遗传算法方法解决MountainCar挑战,我们创建了一个 Python 程序01_solve_mountain_car.py,该程序位于github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_10/01_solve_mountain_car.py。
由于我们为此问题选择的解决方案表示方法是包含 0、1 或 2 整数值的列表,因此这个程序与我们在第四章《组合优化》中用来解决 0-1 背包问题的程序相似,在那里解决方案是以包含 0 和 1 的列表表示的。
以下步骤描述了如何创建该程序的主要部分:
-
我们通过创建MountainCar类的实例开始,这将允许我们为MountainCar挑战打分,评估各种解决方案:
car = mountain_car.MountainCar(RANDOM_SEED)- 由于我们的目标是最小化得分——换句话说,使用最少的步数击中旗帜,或者尽可能接近旗帜——我们定义了一个单一目标,最小化适应度策略:
creator.create("FitnessMin", base.Fitness, weights=(-1.0,))- 现在,我们需要创建一个工具箱操作符,用来生成三个允许的动作值之一——0、1 或 2:
toolbox.register("zeroOneOrTwo", random.randint, 0, 2)- 接下来是一个操作符,它用这些值填充个体实例:
toolbox.register("individualCreator", tools.initRepeat, creator.Individual, toolbox.zeroOneOrTwo, len(car))- 然后,我们指示遗传算法使用
getScore()方法,该方法在前一小节中描述过,启动一个MountainCar环境的回合,并使用给定的个体——一组动作——作为环境的输入,直到回合结束。然后,根据汽车的最终位置评估分数——分数越低越好。如果汽车撞到旗帜,分数甚至可能是负数,具体取决于剩余未使用步骤的数量:
def carScore(individual): return car.getScore(individual), toolbox.register("evaluate", carScore)- 至于遗传操作符,我们从通常的锦标赛选择开始,锦标赛规模为 2。由于我们的解表示是由 0、1 或 2 组成的整数值列表,我们可以像解表示为 0 和 1 值列表时那样,使用二点交叉操作符。
对于变异,与通常用于二进制情况的FlipBit操作符不同,我们需要使用UniformInt操作符,它适用于一系列整数值,并将其配置为 0 到 2 的范围:
toolbox.register("select", tools.selTournament, tournsize=2) toolbox.register("mate", tools.cxTwoPoint) toolbox.register("mutate", tools.mutUniformInt, low=0, up=2, indpb=1.0/len(car))- 此外,我们继续使用精英方法,即名人堂(HOF)成员——当前的最佳个体——总是会原封不动地传递到下一代:
population, logbook = elitism.eaSimpleWithElitism(population, toolbox, cxpb=P_CROSSOVER, mutpb=P_MUTATION, ngen=MAX_GENERATIONS, stats=stats, halloffame=hof, verbose=True)- 运行结束后,我们打印出最佳解并将其保存,以便稍后使用我们在MountainCar类中构建的重放功能进行动画演示:
best = hof.items[0] print("Best Solution = ", best) print("Best Fitness = ", best.fitness.values[0]) car.saveActions(best)运行该算法 80 代,种群规模为 100 时,我们得到以下结果:
gen nevals min avg 0 100 0.708709 1.03242 1 78 0.708709 0.975704 ... 47 71 0.000170529 0.0300455 48 74 4.87566e-05 0.0207197 49 75 -0.005 0.0150622 50 77 -0.005 0.0121327 ... 56 77 -0.02 -0.00321379 57 74 -0.025 -0.00564184 ... 79 76 -0.035 -0.0342 80 76 -0.035 -0.03425 Best Solution = [1, 0, 2, 1, 1, 2, 0, 2, 2, 2, 0, ... , 2, 0, 1, 1, 1, 1, 1, 0] Best Fitness = -0.035
从前面的输出中,我们可以看到,在大约 50 代之后,最佳解开始撞击旗帜,产生零分或更低的分数值。从此以后,最佳解在更少的步骤中撞击旗帜,导致越来越低的分数值。
如我们之前提到的,最佳解在运行结束时被保存,现在我们可以通过运行mountain_car程序来重放它。这个重放展示了我们的解如何驱动汽车在两个山峰之间来回摆动,每次都爬得更高,直到汽车能够爬上左侧的低山。然后,它会反弹回来,这意味着我们已经积累了足够的动能,可以继续爬上右侧的更高山峰,最终撞击旗帜,以下面的截图所示:

图 10.5:MountainCar 仿真——汽车到达目标
尽管解决它非常有趣,但这个环境的设置并不要求我们与其进行动态交互。我们能够通过一系列由我们算法根据小车的初始位置组成的动作来爬上山坡。与此不同,我们即将面对的下一个环境——名为CartPole——要求我们根据最新的观察结果,在任何时间步骤动态计算我们的动作。继续阅读,了解如何实现这一点。
解决 CartPole 环境
CartPole-v1环境模拟了一个杆平衡的过程,杆底部铰接在一个小车上,小车沿着轨道左右移动。保持杆竖直通过施加 1 个单位的力到小车上——每次向右或向左。
在这个环境中,杆像一个摆锤一样开始竖立,并以一个小的随机角度出现,如下图所示:

图 10.6:CartPole 仿真—起始点
我们的目标是尽可能长时间地保持摆锤不倾倒到任一侧——即,最多 500 个时间步骤。每当杆保持竖直时,我们将获得+1 的奖励,因此最大总奖励为 500。若在运行过程中发生以下任何情况,回合将提前结束:
-
杆的角度偏离垂直位置超过 15 度
-
小车距离中心的距离超过 2.4 单位
因此,在这些情况下,最终的奖励将小于 500。
在这个仿真中,期望的action值是以下两个值之一的整数:
-
0:将小车推向左侧
-
1:将小车推向右侧
observation对象包含四个浮动值,保存以下信息:
-
小车位置,在-2.4 到 2.4 之间
-
小车速度,在-Inf 到 Inf 之间
-
杆角度,在-0.418 弧度(-24°)到 0.418 弧度(24°)之间
-
杆角速度,在-Inf 到 Inf 之间
例如,我们可以有一个observation为[0.33676587, 0.3786464, -0.00170739, -0.36586074]。
有关 CartPole-v1 环境的更多信息,请访问gymnasium.farama.org/environments/classic_control/cart_pole/。
在我们提出的解决方案中,我们将在每个时间步骤使用这些值作为输入,以决定采取什么行动。我们将借助基于神经网络的控制器来实现这一点。详细描述见下一个小节。
使用神经网络控制 CartPole
为了成功地完成CartPole挑战,我们希望能够动态响应环境的变化。例如,当杆子开始向一个方向倾斜时,我们可能应该把小车朝那个方向移动,但当杆子开始稳定时,可能需要停止推动。因此,这里的强化学习任务可以被看作是教一个控制器通过将四个可用的输入——小车位置、小车速度、杆子角度和杆子速度——映射到每个时间步的适当动作,来保持杆子的平衡。我们如何实现这种映射呢?
实现这种映射的一个好方法是使用神经网络。正如我们在第九章《深度学习网络的架构优化》中看到的那样,神经网络,比如多层感知器(MLP),可以实现其输入和输出之间的复杂映射。这个映射是通过网络的参数来完成的——即,网络中活跃节点的权重和偏置,以及这些节点实现的传递函数。在我们的案例中,我们将使用一个包含四个节点的单一隐藏层的网络。此外,输入层由四个节点组成,每个节点对应环境提供的一个输入值,而输出层则有一个节点,因为我们只有一个输出值——即需要执行的动作。这个网络结构可以通过以下图示来表示:

图 10.7:用于控制小车的神经网络结构
正如我们已经看到的,神经网络的权重和偏置值通常是在网络训练的过程中设置的。值得注意的是,到目前为止,我们仅仅看到了在使用反向传播算法实施监督学习的过程中训练神经网络——也就是说,在之前的每一种情况中,我们都有一组输入和匹配的输出,网络被训练来将每个给定的输入映射到其匹配的输出。然而,在这里,当我们实践强化学习时,我们并没有这种训练信息。相反,我们只知道网络在每一轮训练结束时的表现如何。这意味着我们需要一种方法来根据通过运行环境的训练轮次获得的结果来找到最佳的网络参数——即权重和偏置,而不是使用传统的训练算法。这正是遗传算法擅长的优化任务——找到一组能够为我们提供最佳结果的参数,只要你有评估和比较这些参数的方法。为了做到这一点,我们需要弄清楚如何表示网络的参数,并且如何评估一组给定的参数。这两个问题将在下一个小节中讨论。
解决方案表示与评估
由于我们决定使用MLP类型的神经网络来控制 CartPole 挑战中的小车,因此我们需要优化的参数集合为网络的权重和偏置,具体如下:
-
输入层:该层不参与网络映射;相反,它接收输入值并将其传递给下一层的每个神经元。因此,这一层不需要任何参数。
-
隐藏层:这一层中的每个节点与四个输入完全连接,因此除了一个偏置值外,还需要四个权重。
-
输出层:这一层的单个节点与隐藏层中的每个四个节点相连,因此除了一个偏置值外,还需要四个权重。
总共有 20 个权重值和 5 个偏置值需要找到,所有值都为float类型。因此,每个潜在解决方案可以表示为 25 个float值的列表,如下所示:
[0.9505049282421143, -0.8068797228337171, -0.45488246459260073, ... ,0.6720551701599038]
评估给定的解决方案意味着创建一个具有正确维度的 MLP——四个输入,一个四节点的隐藏层和一个输出——并将我们浮动列表中的权重和偏置值分配到不同的节点上。然后,我们需要使用这个 MLP 作为小车摆杆的控制器,运行一个回合。回合的总奖励作为此解决方案的得分值。与之前的任务相比,在这里我们旨在最大化得分。这个得分评估过程由CartPole类实现,接下来将深入讨论。
Python 问题表示
为了封装CartPole挑战,我们创建了一个名为CartPole的 Python 类。该类包含在cart_pole.py文件中,位于github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_10/cart_pole.py。
该类通过一个可选的随机种子初始化,并提供以下方法:
-
initMlp():使用所需的网络架构(层)和网络参数(权重和偏置)初始化一个 MLP 回归器,这些参数来自表示候选解决方案的浮动列表。
-
getScore():计算给定解决方案的得分,该解决方案由一组浮点值表示的网络参数表示。通过创建一个相应的 MLP 回归器,初始化CartPole环境的一个回合,并在使用观察作为输入的同时,利用 MLP 控制行动来实现这一点。得分越高,效果越好。
-
saveParams():使用pickle序列化并保存网络参数列表。
-
replayWithSavedParams():反序列化最新保存的网络参数列表,并使用这些参数通过replay方法重放一个回合。
-
replay():渲染环境,并使用给定的网络参数重放一个回合,展示给定的解决方案。
类的主要方法应该在解决方案已序列化并保存后使用,使用saveParams()方法。主方法将初始化类并调用replayWithSavedParams()来渲染并动画化保存的解决方案。
我们通常会使用主方法来动画化遗传算法驱动的解决方案所找到的最佳解决方案,正如下面小节所探讨的那样。
遗传算法解决方案
为了与CartPole环境进行交互并使用遗传算法来解决它,我们创建了一个 Python 程序02_solve_cart-pole.py,该程序位于github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_10/02_solve_cart_pole.py。
由于我们将使用浮动数值列表来表示解决方案——即网络的权重和偏差——这个程序与我们在第六章中看到的函数优化程序非常相似,优化连续函数,例如我们用于Eggholder 函数优化的程序。
以下步骤描述了如何创建此程序的主要部分:
-
我们首先创建一个CartPole类的实例,这将使我们能够测试CartPole挑战的各种解决方案:
cartPole = cart_pole.CartPole(RANDOM_SEED)- 接下来,我们设置浮动数值的上下边界。由于我们所有的数值表示神经网络中的权重和偏差,因此这个范围应该在每个维度内都介于-1.0 和 1.0 之间:
BOUNDS_LOW, BOUNDS_HIGH = -1.0, 1.0- 如你所记得,我们在这个挑战中的目标是最大化分数——即我们能保持杆子平衡的时间。为此,我们定义了一个单一目标,最大化适应度策略:
creator.create("FitnessMax", base.Fitness, weights=(1.0,))- 现在,我们需要创建一个辅助函数,用于在给定范围内均匀分布地生成随机实数。此函数假设每个维度的范围都是相同的,就像我们解决方案中的情况一样:
def randomFloat(low, up): return [random.uniform(l, u) for l, u in zip([low] * \ NUM_OF_PARAMS, [up] * NUM_OF_PARAMS)]- 现在,我们使用此函数创建一个操作符,它会随机返回一个在我们之前设定的范围内的浮动数值列表:
toolbox.register("attrFloat", randomFloat, BOUNDS_LOW, BOUNDS_HIGH)- 紧接着是一个操作符,使用之前的操作符填充个体实例:
toolbox.register("individualCreator", tools.initIterate, creator.Individual, toolbox.getScore() method, which we described in the previous subsection, initiates an episode of the *CartPole* environment. During this episode, the cart is controlled by a single-hidden layer MLP. The weight and bias values of this MLP are populated by the list of floats representing the current solution. Throughout the episode, the MLP dynamically maps the observation values of the environment to an action of *right* or *left*. Once the episode is done, the score is set to the total reward, which equates to the number of time steps that the MLP was able to keep the pole balanced – the higher, the better:def score(individual):
return cartPole.getScore(individual),
toolbox.register("evaluate", score)
- 现在是选择遗传操作符的时候了。我们将再次使用锦标赛选择,并且锦标赛大小为 2,作为我们的选择操作符。由于我们的解决方案表示为一个在给定范围内的浮动数值列表,我们将使用 DEAP 框架提供的专用连续有界交叉和变异操作符——分别是cxSimulatedBinaryBounded和mutPolynomialBounded:
toolbox.register("select", tools.selTournament, tournsize=2) toolbox.register("mate", tools.cxSimulatedBinaryBounded, low=BOUNDS_LOW, up=BOUNDS_HIGH, eta=CROWDING_FACTOR) toolbox.register("mutate", tools.mutPolynomialBounded, low=BOUNDS_LOW, up=BOUNDS_HIGH, eta=CROWDING_FACTOR, indpb=1.0/NUM_OF_PARAMS)另外,像往常一样,我们使用精英策略,即当前最好的个体——HOF 成员——始终会直接传递到下一代:
population, logbook = elitism.eaSimpleWithElitism( population, toolbox, cxpb=P_CROSSOVER, mutpb=P_MUTATION, ngen=MAX_GENERATIONS, stats=stats, halloffame=hof, verbose=True)运行结束后,我们打印出最佳解并保存,以便通过我们在MountainCar类中构建的回放功能进行动画演示:
best = hof.items[0] print("Best Solution = ", best) print("Best Score = ", best.fitness.values[0]) cartPole.saveParams(best)此外,我们将使用我们最好的个体运行 100 次连续的实验,每次都随机初始化 CartPole 问题,因此每个实验都从稍微不同的起始条件开始,可能会得到不同的结果。然后我们将计算所有结果的平均值:
scores = [] for test in range(100): scores.append(cart_pole.CartPole().getScore(best)) print("scores = ", scores) print("Avg. score = ", sum(scores) / len(scores))
现在是时候看看我们在这个挑战中表现得如何了。通过运行 10 代,每代 30 个个体的遗传算法,我们得到了以下结果:
gen nevals max avg
0 30 68 14.4333
1 26 77 21.7667
...
4 27 381 57.2667
5 26 500 105.733
...
9 22 500 207.133
10 26 500 293.267
Best Solution = [-0.7441543221198176, 0.34598771744315737, -0.4221171254602347, ...
Best Score = 500.0
从前面的输出中可以看到,在仅仅五代之后,最好的解达到了 500 的最高分,在整个实验期间平衡了杆子。
从我们额外测试的结果来看,似乎所有 100 次测试都以完美的 500 分结束:
Running 100 episodes using the best solution...
scores = [500.0, 500.0, 500.0, 500.0, 500.0, 500.0, 500.0, 500.0, 500.0, 500.0, 500.0, 500.0, ... , 500.0]
Avg. score = 500.0
正如我们之前提到的,每次这 100 次实验都以略有不同的随机起始点开始。然而,控制器足够强大,可以每次都在整个实验过程中保持杆子的平衡。为了观察控制器的实际效果,我们可以通过启动cart_pole程序来播放 CartPole 实验——或播放多个实验——并查看保存的结果。动画展示了控制器如何通过采取行动动态地响应杆子的运动,使其在整个实验过程中保持在小车上的平衡。
如果你想将这些结果与不完美的结果进行对比,建议你在CartPole类中将HIDDEN_LAYER常量的值改为三(甚至两个)个节点,而不是四个。或者,你可以减少遗传算法的代数和/或种群规模。
总结
在本章中,你了解了强化学习的基本概念。在熟悉了Gymnasium工具包后,你遇到了MountainCar挑战,在这个挑战中,需要控制一辆车使其能够爬上两座山中的较高一座。在使用遗传算法解决了这个挑战后,你接着遇到了下一个挑战——CartPole,在这个挑战中,需要精确控制一辆小车以保持竖直的杆子平衡。我们通过结合基于神经网络的控制器和遗传算法引导的训练成功解决了这个挑战。
虽然我们迄今为止主要关注的是涉及结构化数值数据的问题,但下一章将转向遗传算法在自然语言处理(NLP)中的应用,这是机器学习的一个分支,使计算机能够理解、解释和处理人类语言。
进一步阅读
欲了解更多信息,请参考以下资源:
-
用 Python 精通强化学习,Enes Bilgin,2020 年 12 月 18 日
-
深度强化学习实战,第 2 版,Maksim Lapan,2020 年 1 月 21 日
-
Gymnasium 文档:
-
OpenAI Gym(白皮书),Greg Brockman,Vicki Cheung,Ludwig Pettersson,Jonas Schneider,John Schulman,Jie Tang,Wojciech Zaremba:
第十一章:自然语言处理
本章探讨了遗传算法如何增强自然语言处理(NLP)任务的性能,并深入了解其潜在机制。
本章通过介绍 NLP 领域并解释词嵌入的概念开始。我们运用这一技术,利用遗传算法来玩类似Semantle的神秘词游戏,挑战算法猜测神秘词。
随后,我们研究了n-gram和文档分类。我们利用遗传算法来确定一个紧凑而有效的特征子集,揭示分类器的运作原理。
到本章结束时,你将达到以下目标:
-
熟悉 NLP 领域及其应用
-
理解了词嵌入的概念及其重要性
-
使用词嵌入实现了一个神秘词游戏,并创建了一个由遗传算法驱动的玩家来猜测神秘词
-
获取了有关 n-gram 及其在文档处理中的作用的知识
-
开发了一种过程,显著减少了用于消息分类的特征集大小
-
使用最小特征集来洞察分类器的运作
本章将以快速概述自然语言处理(NLP)开始。如果你是经验丰富的数据科学家,可以跳过引言部分。
技术要求
本章将使用 Python 3,并配备以下支持库:
-
deap
-
numpy
-
pandas
-
matplotlib
-
seaborn
-
scikit-learn
-
gensim——在本章中介绍
重要提示
如果你使用我们提供的requirements.txt文件(参见第三章),这些库已经包含在你的环境中。
本章的代码可以在这里找到:
查看以下视频,看看代码如何运行:
理解 NLP
自然语言处理(NLP)是人工智能的一个迷人分支,专注于计算机与人类语言之间的互动。NLP 结合了语言学、计算机科学和机器学习,使机器能够理解、解释和生成有意义且有用的人类语言。在过去几年中,NLP 在我们的日常生活中扮演着越来越重要的角色,应用范围涵盖多个领域,从虚拟助手和聊天机器人到情感分析、语言翻译和信息检索等。
自然语言处理(NLP)的主要目标之一是弥合人类与机器之间的沟通鸿沟;这是至关重要的,因为语言是人们进行互动和表达思想、观点和愿望的主要媒介。弥合人类与机器之间沟通鸿沟的目标推动了 NLP 领域的显著进展。最近这一领域的一个重要里程碑是大型语言模型(LLMs)的开发,例如 OpenAI 的ChatGPT。
为了创造人机沟通的桥梁,必须有一种方法能够将人类语言转化为数值表示,使机器能够更有效地理解和处理文本数据。一个这样的技术就是使用词嵌入,在下一节中将对此进行描述。
词嵌入
词嵌入是英语(或其他语言)中单词的数值表示。每个单词都使用一个固定长度的实数向量进行编码。这些向量有效地捕捉了与它们所表示的单词相关的语义和上下文信息。
词嵌入是通过训练神经网络(NNs)来创建单词的数值表示,这些神经网络从大量的书面或口语文本中学习,其中具有相似上下文的单词会映射到连续向量空间中的相邻点。
创建词嵌入的常见技术包括Word2Vec、全局词向量表示(GloVe)和fastText。
词嵌入的典型维度可以变化,但常见的选择是 50、100、200 或 300 维。更高维度的嵌入可以捕捉到更多细微的关系,但可能需要更多的数据和计算资源。
例如,“dog”这个词在 50 维的 Word2Vec 嵌入空间中的表示可能如下所示:
[0.11008 -0.38781 -0.57615 -0.27714 0.70521 0.53994 -1.0786 -0.40146 1.1504 -0.5678 0.0038977 0.52878 0.64561 0.47262 0.48549 -0.18407 0.1801 0.91397 -1.1979 -0.5778 -0.37985 0.33606 0.772 0.75555 0.45506 -1.7671 -1.0503 0.42566 0.41893 -0.68327 1.5673 0.27685 -0.61708 0.64638 -0.076996 0.37118 0.1308 -0.45137 0.25398 -0.74392 -0.086199 0.24068 -0.64819 0.83549 1.2502 -0.51379 0.04224 -0.88118 0.7158 0.38519]
这些 50 个值中的每一个代表了在训练数据上下文中“dog”这个词的不同方面。相关的词汇,如“cat”或“pet”,在这个空间中的词向量会接近“dog”向量,表示它们在语义上的相似性。这些嵌入不仅捕捉了语义信息,还保持了单词之间的关系,使得 NLP 模型能够理解单词关系、上下文,甚至句子和文档级的语义。
下图是 50 维向量的二维可视化,代表了各种英语单词。此图是使用t-分布随机邻域嵌入(t-SNE)创建的,t-SNE 是一种常用于可视化和探索词嵌入的降维技术。t-SNE 将词嵌入投影到一个低维空间,同时保持数据点之间的关系和相似性。此图展示了某些单词组(例如水果或动物)之间的接近关系。单词之间的关系也显而易见——例如,“son”和“boy”之间的关系类似于“daughter”和“girl”之间的关系:

图 11.1:词嵌入的二维 t-SNE 图
除了在自然语言处理中的传统作用,词嵌入还可以应用于遗传算法,正如我们在下一节中将看到的那样。
词嵌入和遗传算法
在本书的前几章中,我们实现了多个使用固定长度实值向量(或列表)作为候选解染色体表示的遗传算法示例。鉴于词嵌入使我们能够使用固定长度的实值数字向量来表示单词(如“dog”),这些向量可以有效地作为遗传算法应用中的单词遗传表示。
这意味着我们可以利用遗传算法来解决候选解是英语单词的问题,利用词嵌入作为单词及其遗传表示之间的翻译机制。
为了展示这一概念,我们将通过一个有趣的词汇游戏来演示如何使用遗传算法解决问题,如下节所述。
使用遗传算法找出谜底词
近年来,在线谜词游戏获得了显著的流行。其中一个突出的例子是Semantle,这是一款根据词义来挑战你猜测每日词汇的游戏。
这款游戏会根据你猜测的词与目标词的语义相似度提供反馈,并且具有一个“热与冷”指示器,显示你的猜测与秘密词的接近程度。
在幕后,Semantle 使用词嵌入,特别是 Word2Vec 来表示谜词和玩家的猜测。它通过计算它们表示之间的差异来衡量它们的语义相似度:向量越接近,词汇之间的相似度就越高。游戏返回的相似度分数范围从-100(与答案差异很大)到 100(与答案完全相同)。
在接下来的子章节中,我们将创建两个 Python 程序。第一个程序模拟了 Semantle 游戏,另一个程序则是一个由遗传算法驱动的玩家或解算器,旨在通过最大化游戏的相似度分数来揭示谜底。两个程序都依赖于词嵌入模型;然而,为了保持清晰的区分,模拟现实世界的场景,每个程序都使用其独特的模型。玩家和游戏之间的互动仅限于交换实际的猜测单词及其对应的分数,且不交换嵌入向量。以下是示意图:

图 11.2:Python 模块的组件图及其交互
为了增加额外的神秘感,我们决定使每个程序使用完全不同的嵌入模型。为了使其工作,我们假设两个嵌入模型在词汇表中有显著的重叠。
下一节详细介绍了这些程序的 Python 实现。
Python 实现
我们将首先使用gensim库创建单词嵌入模型的 Python 实现,如下一小节所述。
gensim 库
gensim库是一个多才多艺的 Python 包,主要用于自然语言处理和文本分析任务。gensim通过提供一整套工具,使得处理单词向量的创建、训练和使用变得高效。其主要特点之一是作为预训练单词嵌入模型的提供者,我们将在第一个 Python 模块中利用它,如下所述。
Embeddings 类
我们从一个名为Embeddings的 Python 类开始,该类封装了基于gensim的预训练单词嵌入模型。可以在以下链接找到这个类,它位于embeddings.py文件中:
此类的主要功能如下所示:
-
类的 init() 方法初始化随机种子(如果有),然后使用 _init_model() 和 _download_and_save_model() 私有方法初始化选择的(或默认的)gensim模型。前者从本地文件上传模型的嵌入信息(如果可用)。否则,后者从gensim仓库下载模型,分离用于嵌入的关键部分KeyedVectors,并将其保存在本地以便下次使用:
if not isfile(model_path): self._download_and_save_model(model_path) print(f"Loading model '{self.model_name}' from local file...") self.model = KeyedVectors.load_word2vec_format(model_path, binary=True) -
pick_random_embedding() 方法可用于从模型的词汇表中随机选择一个词。
-
get_similarity() 方法用于检索模型在两个指定词之间的相似性值。
-
vec2_nearest_word() 方法利用 gensim 模型的 similar_by_vector() 方法检索与指定嵌入向量最接近的词。很快我们将看到,这使得遗传算法可以使用任意向量(例如随机生成的向量),并使它们代表模型词汇表中的现有词。
-
最后,list_models() 方法可用于检索和显示gensim库提供的可用嵌入模型的信息。
如前所述,这个类被Player和Game组件共同使用,将在下一小节中讨论。
MysteryWordGame 类
MysteryWordGame Python 类封装了 Game 组件。它可以在以下链接的 mystery_word_game.py 文件中找到:
该类的主要功能如下:
-
该类使用了斯坦福大学开发的glove-twitter-50 gensim 预训练嵌入模型。该模型专门为 Twitter 文本数据设计,使用了 50 维的嵌入向量。
-
该类的 init() 方法初始化它将内部使用的嵌入模型,然后随机选择一个神秘单词或使用作为参数传递的指定单词:
self.embeddings = Embeddings(model_name=MODEL) self.mystery_word = given_mystery_word if given_mystery_word else self.embeddings.pick_random_embedding() -
score_guess() 方法计算游戏返回的给定猜测单词的得分。如果该单词不在模型的词汇表中(可能是因为玩家模块使用了一个可能不同的模型),则得分设置为最小值 -100。否则,计算出的得分值将是一个介于 -100 和 100 之间的数字:
if self.embeddings.has_word(guess_word): score = 100 * self.embeddings.get_similarity(self.mystery_word, guess_word) else: score = -100 -
main() 方法通过创建游戏的实例来测试该类的功能,选择单词 "dog",并评估与其相关的多个猜测单词,例如 "canine" 和 "hound"。它还包括一个不相关的单词("computer")和一个在词汇表中不存在的单词("asdghf"):
game = MysteryWordGame(given_mystery_word="dog") print("-- Checking candidate guess words:") for guess_word in ["computer", "asdghf", "canine", "hound", "poodle", "puppy", "cat", "dog"]: score = game.score_guess(guess_word) print(f"- current guess: {guess_word.ljust(10)} => score = {score:.2f}")
执行该类的 main() 方法会产生以下输出:
Loading model 'glove-twitter-50' from local file...
--- Mystery word is 'dog' — game on!
-- Checking candidate guess words:
- current guess: computer => score = 54.05
- current guess: asdghf => score = -100.00
- current guess: canine => score = 47.07
- current guess: hound => score = 64.93
- current guess: poodle => score = 65.90
- current guess: puppy => score = 87.90
- current guess: cat => score = 94.30
- current guess: dog => score = 100.00
我们现在已经准备好进入有趣的部分——试图解决游戏的程序。
基于遗传算法的玩家程序
如前所述,该模块使用了与游戏中使用的模型不同的嵌入模型,尽管它也可以选择使用相同的模型。在这种情况下,我们选择了 glove-wiki-gigaword-50 gensim 预训练嵌入模型,该模型是在来自英语 Wikipedia 网站和 Gigaword 数据集的大量语料库上训练的。
解的表示
在此案例中,遗传算法中的解表示为一个实值向量(或列表),其维度与嵌入模型相同。这使得每个解可以作为一个嵌入向量,尽管并不完全完美。最初,算法使用随机生成的向量,并通过交叉和变异操作,至少可以保证部分向量不会直接与模型词汇中的现有单词对应。为了解决这个问题,我们使用 Embedding 类中的 vec2_nearest_word() 方法,该方法返回词汇中最接近的单词。这种方法体现了基因型到表型映射的概念,如在第四章《组合优化》中讨论的那样。
早期收敛标准
在迄今讨论的大多数情况下,解决方案并不知道在优化过程中可以达到的最佳得分。然而,在这种情况下,我们知道最佳得分是 100。一旦达到,就找到了正确的单词,继续进化循环就没有意义了。因此,我们修改了遗传算法的主循环以在达到最大分数时中断。修改后的方法称为 eaSimple_modified(),可以在 elitism_modified.py 模块中找到。它接受一个名为 max_fitness 的可选参数。当此参数提供了一个值时,如果迄今为止找到的最佳适应度值达到或超过此值,则主循环中断:
if max_fitness and halloffame.items[0].fitness.values[0] >=
max_fitness:
break
打印出当前猜测最佳单词
此外,eaSimple_modified() 方法包括打印与迄今为止找到的最佳适应度个体对应的猜测单词,作为为每个个体生成的统计摘要的一部分:
if verbose:
print(f"{logbook.stream} => {embeddings.vec2_nearest_word(
np.asarray(halloffame.items[0]))}")
遗传算法实现
基于遗传算法的玩家为神秘单词游戏寻找最佳超参数值,由位于以下链接的 01_find_mystery_word.py Python 程序实现:
以下步骤描述了这个程序的主要部分:
-
我们首先创建一个 Embeddings 类的实例,它将作为解决程序的词嵌入模型:
embeddings = Embeddings(model_name='glove-wiki-gigaword-50', randomSeed=RANDOM_SEED) VECTOR_SIZE = embeddings.get_vector_size() -
接下来,我们创建 MysteryWordGame 类的一个实例,代表我们将要玩的游戏。我们指示它使用单词“dog”作为演示目的。稍后可以用其他单词替换这个词,或者如果省略 given_mystery_word 参数,我们可以让游戏选择一个随机单词:
game = MysteryWordGame(given_mystery_word='dog') -
由于我们的目标是最大化游戏的得分,我们定义了一个单目标策略来最大化适应度:
creator.create("FitnessMax", base.Fitness, weights=(1.0,)) -
要创建表示词嵌入的随机个体,我们创建一个 randomFloat() 函数,并将其注册到工具箱中:
def randomFloat(low, up): return [random.uniform(l, u) for l, u in zip([low] * VECTOR_SIZE, [up] * VECTOR_SIZE)] toolbox.register("attrFloat", randomFloat, BOUNDS_LOW, BOUNDS_HIGH) -
score() 函数用于评估每个解决方案的适应度,这个过程包括两个步骤:首先,我们使用本地 embeddings 模型找到评估向量最接近的词汇单词(这是基因型到表现型映射发生的地方)。接下来,我们将这个词汇发送到 Game 组件,并请求其评分作为猜测的单词。游戏返回的分数,一个从 -100 到 100 的值,直接用作适应度值:
def score(individual): guess_word = embeddings.vec2_nearest_word( np.asarray(individual)) return game.score_guess(guess_word), toolbox.register("evaluate", score) -
现在,我们需要定义遗传操作符。对于选择操作符,我们使用常见的tournament selection(锦标赛选择),锦标赛大小为 2;而对于交叉和变异操作符,我们选择专门针对有界浮动列表染色体的操作符,并为每个超参数定义了相应的边界:
toolbox.register("select", tools.selTournament, tournsize=2) toolbox.register("mate", tools.cxSimulatedBinaryBounded, low=BOUNDS_LOW, up=BOUNDS_HIGH, eta=CROWDING_FACTOR) toolbox.register("mutate", tools.mutPolynomialBounded, low=BOUNDS_LOW, up=BOUNDS_HIGH, eta=CROWDING_FACTOR, indpb=1.0 / NUM_OF_PARAMS) -
此外,我们继续使用精英主义方法,其中名人堂(HOF)成员——当前最优个体——始终被无修改地传递到下一代。然而,在本次迭代中,我们使用了eaSimple_modified算法,其中——此外——主循环将在得分达到已知的最高分时终止:
population, logbook = eaSimple_modified( population, toolbox, cxpb=P_CROSSOVER, mutpb=P_MUTATION, ngen=MAX_GENERATIONS, max_fitness=MAX_SCORE, stats=stats, halloffame=hof, verbose=True)
通过运行算法,种群大小为 30,得到了以下结果:
Loading model 'glove-wiki-gigaword-50' from local file...
Loading model 'glove-twitter-50' from local file...
--- Mistery word is 'dog' — game on!
gen nevals max avg
0 30 51.3262 -43.8478 => stories
1 25 51.3262 -17.5409 => stories
2 26 51.3262 -1.20704 => stories
3 26 51.3262 11.1749 => stories
4 26 64.7724 26.23 => bucket
5 25 64.7724 40.0518 => bucket
6 26 67.487 42.003 => toys
7 26 69.455 37.0863 => family
8 25 69.455 48.1514 => family
9 25 69.455 38.5332 => family
10 27 87.2265 47.9803 => pet
11 26 87.2265 46.3378 => pet
12 27 87.2265 40.0165 => pet
13 27 87.2265 52.6842 => pet
14 26 87.2265 59.186 => pet
15 27 87.2265 41.5553 => pet
16 27 87.2265 49.529 => pet
17 27 87.2265 50.9414 => pet
18 27 87.2265 44.9691 => pet
19 25 87.2265 30.8624 => pet
20 27 100 63.5354 => dog
Best Solution = dog
Best Score = 100.00
从这个输出中,我们可以观察到以下几点:
-
加载了两个不同的词嵌入模型,一个用于玩家,另一个用于游戏,按照设计进行。
-
设置为‘狗’的神秘词汇,在 20 代之后被遗传算法驱动的玩家正确猜测。
-
一旦找到词汇,玩家就停止了游戏,尽管最大代数设置为 1000。
-
我们可以看到当前最优猜测词汇的演变过程:
-
故事 → 桶 → 玩具 → 家庭 → 宠物 → 狗
这看起来很棒!不过,请记住这只是一个示例。我们鼓励你尝试其他词汇,以及调整遗传算法的不同设置;也许还可以改变嵌入模型。是否有某些模型对比其他模型兼容性差呢?
本章的下一部分,我们将探索文档分类。
文档分类
文档分类是自然语言处理中的一项关键任务,涉及根据文本内容将文档分到预定义的类别或类目中。这个过程对于组织、管理和从大量文本数据中提取有意义的信息至关重要。文档分类的应用广泛,涵盖各行各业和领域。
在信息检索领域,文档分类在搜索引擎中扮演着至关重要的角色。通过将网页、文章和文档分类到相关的主题或类型,搜索引擎可以为用户提供更精确、更有针对性的搜索结果。这提升了整体用户体验,确保用户能够快速找到所需信息。
在客户服务和支持中,文档分类能够实现自动路由客户咨询和信息到相关部门或团队。例如,公司收到的电子邮件可以分类为“账单查询”、“技术支持”或“一般咨询”,确保每一条消息都能及时传达给正确的团队进行处理。
在法律领域,文档分类在电子发现等任务中至关重要,其中需要分析大量的法律文档以确定它们是否与案件相关。分类有助于识别潜在与法律事务相关的文档,从而简化审查过程,减少法律程序所需的时间和资源。
此外,文档分类在情感分析中至关重要,可以用来将社交媒体帖子、评论和意见分类为正面、负面或中性情感。这些信息对于希望评估客户反馈、监控品牌声誉并做出数据驱动决策以改进产品或服务的企业来说是无价的。
执行文档分类的一个有效方法是利用 n-gram,详细内容将在接下来的部分中讲解。
N-gram
n-gram 是由n个项目组成的连续序列,这些项目可以是字符、单词或甚至短语,从更大的文本中提取出来。通过将文本分解成这些较小的单位,n-gram 能够提取出有价值的语言模式、关系和语境。
例如,在字符 n-gram的情况下,3-gram 可能会将单词“apple”拆分为“app”,“ppl”和“ple”。
这里有一些单词 n-gram的示例:
-
单元组(1-gram):
文本:“我爱编程。”
单元组:[“我”,“爱”,“编程”]
-
二元组(2-gram):
文本:“自然语言处理是迷人的。”
二元组:[“自然语言”,“语言处理”,“处理是”,“是迷人”]
-
三元组(3-gram):
文本:“机器学习模型可以泛化。”
三元组:[“机器学习模型”,“学习模型可以”,“模型可以泛化”]
N-gram 通过揭示单词或字符的顺序排列,识别频繁的模式并提取特征,从而提供对文本内容的洞察。它们有助于理解语言结构、语境和模式,使其在文档分类等文本分析任务中非常有价值。
选择 n-gram 的一个子集
在第七章,“通过特征选择增强机器学习模型”中,我们展示了选择有意义特征子集的重要性,这一过程在文档分类中同样具有价值,尤其是当处理大量提取的 n-gram 时,这在大型文档中很常见。识别相关 n-gram 子集的优势包括以下几点:
-
降维:减少 n-gram 的数量可以提高计算效率,防止过拟合
-
关注关键特征:选择具有区分性的 n-gram 有助于模型集中关注关键特征
-
噪声减少:过滤掉无信息的 n-gram 最小化数据中的噪声
-
增强泛化能力:精心选择的子集提高了模型处理新文档的能力
-
效率:较小的特征集加速模型的训练和预测
此外,在文档分类中识别相关的 n-gram 子集对于模型可解释性非常重要。通过将特征缩小到一个可管理的子集,理解和解释影响模型预测的因素变得更加容易。
类似于我们在第七章中所做的,我们将在这里应用基因算法搜索,以识别相关的 n-gram 子集。然而,考虑到我们预期的 n-gram 数量远大于我们之前使用的常见数据集中的特征数量,我们不会寻找整体最好的子集。相反,我们的目标是找到一个固定大小的特征子集,例如最好的 1,000 个或 100 个 n-gram。
使用基因算法搜索固定大小的子集
由于我们需要在一个非常大的群体中识别一个良好的、固定大小的子集,下面我们尝试定义基因算法所需的常见组件:
-
解空间表示:由于子集的大小远小于完整数据集,因此使用表示大数据集中项索引的固定大小整数列表更加高效。例如,如果我们旨在从 100 个项目中创建一个大小为 3 的子集,则一个可能的解决方案可以表示为列表,如[5, 42, 88]或[73, 11, 42]。
-
交叉操作:为了确保有效的后代,我们必须防止同一个索引在每个后代中出现多次。在前面的示例中,项“42”出现在两个列表中。如果我们使用单点交叉,可能会得到后代[5, 42, 42],这实际上只有两个唯一的项,而不是三个。一种克服这个问题的简单交叉方法如下:
-
创建一个集合,包含两个父代中所有唯一的项。在我们的示例中,这个集合是{5, 11, 42, 73, 88}。
-
通过从前面提到的集合中随机选择生成后代。每个后代应该选择三个项(在本例中)。可能的结果可以是[5, 11, 88]和[11, 42, 88]。
-
-
变异操作:生成一个有效变异个体的简单方法如下:
-
对于列表中的每个项,按指定的概率,将该项替换为当前列表中存在的项。
-
例如,如果我们考虑列表[11, 42, 88],则有可能将第二个项(42)替换为 27,得到列表[11, 27, 88]。
-
Python 实现
在接下来的章节中,我们将实现以下内容:
-
一个文档分类器,它将在来自两个新闻组的文档数据上训练,并使用 n-gram 来预测每个文档属于哪个新闻组
-
一个由基因算法驱动的优化器,旨在根据所需的子集大小,寻找用于此分类任务的最佳 n-gram 子集
我们将从实现分类器的类开始,如下一个子章节所述:
新闻组文档分类器
我们从一个名为NewsgroupClassifier的 Python 类开始,实现一个基于scikit-learn的文档分类器,该分类器使用 n-gram 作为特征,并学习区分来自两个不同新闻组的帖子。该类可以在newsgroup_classifier.py文件中找到,该文件位于以下链接:
该类的主要功能如下所示:
-
该类的init_data()方法由init()调用,从scikit-learn的内置新闻组数据集中创建训练集和测试集。它从两个类别'rec.autos'和'rec.motorcycles'中检索帖子,并进行预处理,去除标题、页脚和引用:
categories = ['rec.autos', 'rec.motorcycles'] remove = ('headers', 'footers', 'quotes') newsgroups_train = fetch_20newsgroups(subset='train', categories=categories, remove=remove, shuffle=False) newsgroups_test = fetch_20newsgroups(subset='test', categories=categories, remove=remove, shuffle=False) -
接下来,我们创建两个TfidfVectorizer对象:一个使用 1 到 3 个词的词 n-gram,另一个使用 1 到 10 个字符的字符 n-gram。这些向量化器根据每个文档中 n-gram 的相对频率与整个文档集合进行比较,将文本文档转换为数值特征向量。然后,这两个向量化器被合并成一个vectorizer实例,以从提供的新闻组消息中提取特征:
word_vectorizer = TfidfVectorizer(analyzer='word', sublinear_tf=True, max_df=0.5, min_df=5, stop_words="english", ngram_range=(1, 3)) char_vectorizer = TfidfVectorizer(analyzer='char', sublinear_tf=True, max_df=0.5, min_df=5, ngram_range=(1, 10)) vectorizer = FeatureUnion([('word_vectorizer', word_vectorizer), ('char_vectorizer', char_vectorizer)]) -
我们通过允许vectorizer实例从训练数据中“学习”相关的 n-gram 信息,然后将训练数据和测试数据转换为包含相应 n-gram 特征的向量数据集:
self.X_train = vectorizer.fit_transform(newsgroups_train.data) self.y_train = newsgroups_train.target self.X_test = vectorizer.transform(newsgroups_test.data) self.y_test = newsgroups_test.target -
get_predictions()方法生成“简化”版本的训练集和测试集,利用通过features_indices参数提供的特征子集。随后,它使用MultinomialNB的一个实例,这是一个在文本分类中常用的分类器,它在简化后的训练集上进行训练,并为简化后的测试集生成预测:
reduced_X_train = self.X_train[:, features_indices] reduced_X_test = self.X_test[:, features_indices] classifier = MultinomialNB(alpha=.01) classifier.fit(reduced_X_train, self.y_train) return classifier.predict(reduced_X_test) -
get_accuracy()和get_f1_score()方法使用get_predictions()方法来分别计算和返回分类器的准确率和 f-score:
-
main()方法产生以下输出:Initializing newsgroup data... Number of features = 51280, train set size = 1192, test set size = 794 f1 score using all features: 0.8727376310606889 f1 score using random subset of 100 features: 0.589931144127823
我们可以看到,使用所有 51,280 个特征时,分类器能够达到 0.87 的 f1-score,而使用 100 个随机特征子集时,得分降至 0.59。让我们看看使用遗传算法选择特征子集是否能帮助我们接近更高的得分。
使用遗传算法找到最佳特征子集
基于遗传算法的搜索,用于寻找 100 个特征的最佳子集(从原始的 51,280 个特征中挑选),是通过02_solve_newsgroups.py Python 程序实现的,程序位于以下链接:
以下步骤描述了该程序的主要部分:
-
我们通过创建NewsgroupClassifier类的一个实例,来测试不同的固定大小特征子集:
ngc = NewsgroupClassifier(RANDOM_SEED) -
然后,我们定义了两个专门的固定子集遗传操作符,cxSubset()——实现交叉——和mutSubset()——实现变异,正如我们之前所讨论的那样。
-
由于我们的目标是最大化分类器的 f1 分数,我们定义了一个单一目标策略来最大化适应度:
creator.create("FitnessMax", base.Fitness, weights=(1.0,)) -
为了创建表示特征索引的随机个体,我们创建了randomOrder()函数,该函数利用random.sample()在 51,280 的期望范围内生成一个随机的索引集。然后,我们可以使用这个函数来创建个体:
toolbox.register("randomOrder", random.sample, range(len(ngc)), SUBSET_SIZE) toolbox.register("individualCreator", tools.initIterate, creator.Individual, toolbox.randomOrder) -
get_score()函数用于评估每个解(或特征子集)的适应度,它通过调用NewsgroupClassifier实例的get_f1_score()方法来实现:
def get_score(individual): return ngc.get_f1_score(individual), toolbox.register("evaluate", get_score) -
现在,我们需要定义遗传操作符。对于选择操作符,我们使用常规的锦标赛选择,锦标赛大小为 2;而对于交叉和变异操作符,我们选择之前定义的专用函数:
toolbox.register("select", tools.selTournament, tournsize=2) toolbox.register("mate", cxSubset) toolbox.register("mutate", mutSubset, indpb=1.0/SUBSET_SIZE) -
最后,是时候调用遗传算法流程,我们继续使用精英主义方法,其中 HOF 成员——当前最佳个体——始终不加修改地传递到下一代:
population, logbook = eaSimple( population, toolbox, cxpb=P_CROSSOVER, mutpb=P_MUTATION, ngen=MAX_GENERATIONS, stats=stats, halloffame=hof, verbose=True)
通过运行该算法 5 代,种群大小为 30,我们得到以下结果:
Initializing newsgroup data...
Number of features = 51280, train set size = 1192, test set size = 794
gen nevals max avg
0 200 0.639922 0.526988
1 166 0.639922 0.544121
2 174 0.663326 0.557525
3 173 0.669138 0.574895
...
198 170 0.852034 0.788416
199 172 0.852034 0.786208
200 167 0.852034 0.788501
-- Best Ever Fitness = 0.8520343720882079
-- Features subset selected =
1: 5074 = char_vectorizer__ crit
2: 12016 = char_vectorizer__=oo
3: 18081 = char_vectorizer__d usi
...
结果表明,我们成功识别出了一个包含 100 个特征的子集,f1 分数为 85.2%,这一结果与使用全部 51,280 个特征得到的 87.2%分数非常接近。
在查看显示最大适应度和平均适应度随代数变化的图表时,接下来的结果显示,如果我们延长进化过程,可能会获得进一步的改进:

图 11.3:程序搜索最佳特征子集的统计数据
进一步减少子集大小
如果我们希望进一步将子集大小减少到仅有 10 个特征呢?结果可能会让你惊讶。通过将SUBSET_SIZE常数调整为 10,我们依然取得了一个值得称赞的 f1 得分:76.1%。值得注意的是,当我们检查这 10 个选定的特征时,它们似乎是一些熟悉单词的片段。在我们的分类任务中,任务是区分专注于摩托车的新闻组和与汽车相关的帖子,这些特征开始展现出它们的相关性:
-- Features subset selected =
1: 16440 = char_vectorizer__car
2: 18813 = char_vectorizer__dod
3: 50905 = char_vectorizer__yamah
4: 18315 = char_vectorizer__dar
5: 10373 = char_vectorizer__. The
6: 6586 = char_vectorizer__ mu
7: 4747 = char_vectorizer__ bik
8: 4439 = char_vectorizer__ als
9: 15260 = char_vectorizer__ave
10: 40719 = char_vectorizer__rcy
移除字符 n-gram
以上结果引发了一个问题:我们是否应该仅使用单词 n-gram,而去除字符 n-gram 呢?我们可以通过使用一个单一的向量化器来实现,具体方法如下:
newsgroup_classifier.py program are as follows:
正在初始化新闻组数据...
特征数量 = 2666,训练集大小 = 1192,测试集大小 = 794
使用所有特征的 f1 得分:0.8551359241014413
使用 100 个随机特征子集的 f1 得分:0.6333756056319708
These results suggest that exclusively using word n-grams can achieve comparable performance to the original approach while using a significantly smaller feature set (2,666 features).
If we now run the genetic algorithm again, the results are the following:
-- 最佳健身得分 = 0.750101164515984
-- 选定的特征子集 =
1: 1669 = 换油
2: 472 = 汽车
3: 459 = 汽车
4: 361 = 自行车
5: 725 = 检测器
6: 303 = 汽车
7: 296 = 自动
8: 998 = 福特
9: 2429 = 丰田
10: 2510 = v6
This set of selected features makes a lot of sense within the context of our classification task and provides insights into how the classifier operates.
Summary
In this chapter, we delved into the rapidly evolving field of NLP. We began by exploring word embeddings and their diverse applications. Our journey led us to experiment with solving the mystery-word game using genetic algorithms, where word embedding vectors served as the genetic chromosome. Following this, we ventured into n-grams and their role in document classification through a newsgroup message classifier. In this context, we harnessed the power of genetic algorithms to identify a compact yet effective subset of n-gram features derived from the dataset.
Finally, we endeavored to minimize the feature subset, aiming to gain insights into the classifier’s operations and interpret the factors influencing its predictions. In the next chapter, we will delve deeper into the realm of explainable and interpretable AI while applying genetic algorithms.
Further reading
For more information on the topics that were covered in this chapter, please refer to the following resources:
* *Hands-On Python Natural Language Processing* by *Aman Kedia* and *Mayank Rasu*, *June* *26, 2020*
* *Semantle* word game: [`semantle.com/`](https://semantle.com/)
* **scikit-learn** 20 newsgroups dataset: [`scikit-learn.org/stable/modules/generated/sklearn.datasets.fetch_20newsgroups.html`](https://scikit-learn.org/stable/modules/generated/sklearn.datasets.fetch_20newsgroups.html)
* **scikit-learn** **TfidfVectorizer**: [`scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.TfidfVectorizer.html`](https://scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.TfidfVectorizer.html)
第十二章:可解释人工智能、因果关系与基因算法中的反事实
本章探讨了基因算法在生成“假设”场景中的应用,提供了对数据集和相关机器学习模型分析的宝贵见解,并为实现预期结果提供了可操作的洞察。
本章开始时介绍了 可解释人工智能(XAI)和 因果关系 领域,随后解释了 反事实 的概念。我们将使用这一技术探索无处不在的 德国信用风险 数据集,并利用基因算法对其进行反事实分析,从中发现有价值的洞察。
到本章结束时,你将能够做到以下几点:
-
熟悉 XAI 和因果关系领域及其应用
-
理解反事实的概念及其重要性
-
熟悉德国信用风险数据集及其缺点
-
实现一个应用程序,创建反事实的“假设”场景,为这个数据集提供可操作的洞察,并揭示其相关机器学习模型的操作。
本章将从对 XAI 和因果关系的简要概述开始。如果你是经验丰富的数据科学家,可以跳过这一介绍部分。
技术要求
在本章中,我们将使用 Python 3 和以下支持库:
-
deap
-
numpy
-
pandas
-
scikit-learn
重要提示
如果你使用的是我们提供的 requirements.txt 文件(见 第三章),这些库已经包含在你的环境中。
本章中将使用的程序可以在本书的 GitHub 仓库中找到,网址是 github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/tree/main/chapter_12。
查看以下视频,观看代码演示:
解锁黑盒 – XAI
XAI 是 人工智能(AI)领域的一个关键元素,旨在揭示机器学习模型复杂的工作原理。随着人工智能应用的不断增长,理解模型的决策过程变得至关重要,以建立信任并确保负责任的部署。
XAI 旨在解决此类模型固有的复杂性和不透明性问题,并提供清晰且易于理解的预测解释。这种透明性不仅增强了 AI 模型的可解释性,还使用户、利益相关者和监管机构能够审视和理解这些过程。在医疗保健和金融等关键领域,决策具有现实世界的后果,XAI 显得尤为重要。例如,在医学诊断中,可解释的模型不仅提供准确的预测,还揭示了影响诊断的医学图像或患者记录中的具体特征,从而建立信任并符合伦理标准。
实现可解释性的有效方法之一是通过模型无关技术。这些方法为任何机器学习模型提供事后(“事后解释”)的解释,不论其架构如何。像SHAP 值和LIME等技术,通过对输入数据或模型参数进行小幅、受控的调整,生成解释,从而揭示对预测贡献最大的特征。
在 XAI 的基础上,因果关系通过探索“是什么背后的‘为什么’”为模型提供了更深层次的解释,正如下节所述。
揭示因果关系——AI 中的因果性
不仅仅是了解 AI 的预测结果,还要理解这些预测背后的因果关系,这在决策具有重大影响的领域尤为重要。
在 AI 中,因果性探索数据的各个方面的变化是否会影响模型的预测或决策。例如,在医疗保健领域,了解患者参数与预测结果之间的因果关系有助于更有效地量身定制治疗方案。目标不仅是准确的预测,还要理解这些预测背后的机制,以便为数据提供更加细致且可操作的洞察。
假设情景——反事实
反事实通过探索“假如”情景并考虑替代结果,进一步增强了 AI 系统的可解释性。反事实解释帮助我们了解如何通过调整输入来影响模型预测,通过微调这些输入并观察模型决策中的变化(或没有变化)。这一过程本质上提出了“如果呢?”的问题,并使我们能够获得有关 AI 模型敏感性和鲁棒性的有价值的洞察。
例如,假设一个 AI 驱动的汽车决定避开行人。通过反事实分析,我们可以揭示在不同条件下这一决策如何发生变化,从而为模型的行为提供有价值的洞察。另一个例子是推荐系统。反事实分析可以帮助我们理解如何调整某些用户偏好,可能会改变推荐的项目,从而为用户提供更清晰的系统工作原理,并使开发者能够提高用户满意度。
除了提供对模型行为的更深理解外,反事实分析还可以用于模型改进和调试。通过探索替代场景并观察变化如何传播,开发人员可以发现潜在的弱点、偏差或优化空间。
正如我们在以下章节中所示,探索“假设”场景也可以使用户预测和解读 AI 系统的响应。
遗传算法在反事实分析中的应用——导航替代场景
遗传算法作为执行反事实分析的有用工具,提供了一种灵活的方式来修改模型输入,从而达到预期的结果。在这里,遗传算法中的每个解代表一个独特的输入组合。优化目标取决于模型的输出,并可以结合与输入值相关的条件,例如限制变化或最大化某个特定输入值。
在接下来的章节中,我们将利用遗传算法对一个机器学习模型进行反事实分析,该模型的任务是确定贷款申请人的信用风险。通过这种探索,我们旨在回答有关特定申请人的各种问题,深入了解模型的内在运作。此外,这一分析还可以提供可操作的信息,帮助申请人提高获得贷款的机会。
让我们首先熟悉将用于训练模型的数据集。
德国信用风险数据集
在本章的实验中,我们将使用经过修改的德国信用风险数据集,该数据集在机器学习和统计学领域的研究和基准测试中被广泛使用。原始数据集可以从UCI 机器学习库访问,包含 1,000 个实例,每个实例有 20 个属性。该数据集旨在进行二分类任务,目的是预测贷款申请人是否值得信贷或存在信用风险。
按照现代标准,数据集中某些原始属性被认为是受保护的,特别是代表候选人性别和年龄的属性。在我们修改后的版本中,这些属性已被排除。此外,其他一些特征要么被删除,要么其值已被转换为数值格式,以便简化处理。
修改后的数据集可以在chapter_12/data/credit_risk_data.csv文件中找到,包含以下列:
-
checking: 申请人支票账户的状态:
-
0: 没有支票账户
-
1: 余额 < 100 德国马克
-
2: 100 <= 余额 < 200 德国马克
-
3: 余额 >= 200 德国马克
-
-
duration: 申请贷款的时长(月数)
-
credit_history: 申请人的信用历史信息:
-
0: 没有贷款/所有贷款已按时偿还
-
1: 现有贷款已按时偿还
-
2: 本银行的所有贷款均已按时还清
-
3: 过去曾有还款延迟
-
4: 存在重要账户/其他贷款
-
-
金额: 申请贷款的金额
-
储蓄: 申请人的储蓄账户状态:
-
0: 未知/没有储蓄账户
-
1: 余额 < 100 德国马克
-
2: 100 <= 余额 < 500 德国马克
-
3: 500 <= 余额 < 1000 德国马克
-
4: 余额 >= 1000 德国马克
-
-
就业时长:
-
0: 失业
-
1: 时长 < 1 年
-
2: 1 <= 时长 < 4 年
-
3: 4 <= 时长 < 7 年
-
4: 时长 >= 7 年
-
-
其他债务人: 除主要申请人外,任何可能是共同债务人或共同承担贷款财务责任的个人:
-
无
-
担保人
-
共同申请人
-
-
现住址: 申请人在当前地址的居住时长,用 1 到 4 之间的整数表示
-
住房: 申请人的住房情况:
-
免费
-
自有
-
租赁
-
-
信用账户数: 在同一银行持有的信用账户数量
-
负担人: 申请人经济上依赖的人的数量
-
电话: 申请人是否有电话(1 = 是,0 = 否)
-
信用风险: 要预测的值:
-
1: 高风险(表示违约或信用问题的可能性较高)
-
0: 低风险
-
为了说明,我们提供了数据的前 10 行:
1,6,4,1169,0,4,none,4,own,2,1,1,1
2,48,1,5951,1,2,none,2,own,1,1,0,0
0,12,4,2096,1,3,none,3,own,1,2,0,1
1,42,1,7882,1,3,guarantor,4,for free,1,2,0,1
1,24,3,4870,1,2,none,4,for free,2,2,0,0
0,36,1,9055,0,2,none,4,for free,1,2,1,1
0,24,1,2835,3,4,none,4,own,1,1,0,1
2,36,1,6948,1,2,none,2,rent,1,1,1,1
0,12,1,3059,4,3,none,4,own,1,1,0,1
2,30,4,5234,1,0,none,2,own,2,1,0,0
虽然在以往的数据集工作中,我们的主要目标是开发一个机器学习模型,以对新数据做出精准的预测,但现在我们将使用反事实情境来扭转局面,并识别出与期望预测匹配的数据。
探索用于信用风险预测的反事实情境
从数据中可以看出,许多申请人被认为是信用风险(最后的值为1),导致贷款被拒。对于这些申请人,提出以下问题:他们能采取什么措施改变这一决定,并被视为有信用?(结果为0)。这里所说的措施是指更改他们的某些属性状态,例如他们申请的借款金额。
在检查数据集时,一些属性对于申请人来说是困难或甚至不可能改变的,比如就业时长、抚养人数或当前住房。对于我们的例子,我们将重点关注以下四个属性,它们都有一定的灵活性:
-
金额: 申请贷款的金额
-
时长: 申请贷款的时长(以月为单位)
-
支票账户: 申请人的支票账户状态
-
储蓄: 申请人的储蓄账户状态
现在问题可以这样表述:对于一个目前被标记为信用风险的候选人,我们可以对这四个属性(或其中一些)进行哪些最小的变化,使结果变为信用良好?
为了回答这个问题,以及其他相关问题,我们将创建以下内容:
-
一个已经在我们的数据集上训练的机器学习模型。该模型将被用来提供预测,从而在修改申请人数据时测试潜在的结果。
-
基于遗传算法的解决方案,寻找新的属性值以回答我们的问题。
这些组件将使用 Python 实现,如下文所述。
Applicant 类
Applicant类表示数据集中的一个申请人;换句话说,就是 CSV 文件中的一行数据。该类还允许我们修改amount、duration、checking和savings字段的值,这些字段代表了申请人的相应属性。此类可以在applicant.py文件中找到,文件位于github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_12/applicant.py。
该类的主要功能如下所示:
-
该类的init()方法使用dataset_row参数的值从对应的数据集行中复制值,并创建一个代表申请人的实例。
-
除了先前提到的四个属性的设置器和获取器外,get_values()方法返回四个属性的当前值,而with_values()方法则创建原始申请人实例的副本,并随之修改这四个属性的复制值。这两个方法都使用整数列表来表示四个属性的值,因为它们将被遗传算法直接使用,遗传算法将潜在的申请人表示为四个整数的列表。
CreditRiskData 类
CreditRiskData类封装了信用风险数据集及其上训练的机器学习模型。该类位于credit_risk_data.py文件中,可以在github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_12/credit_risk_data.py找到。
该类的主要功能在以下步骤中体现:
-
该类的init()方法初始化随机种子;然后调用read_dataset()方法,该方法从 CSV 文件中读取数据集:
self.randomSeed = randomSeed self.dataset = self.read_dataset() -
接下来,它检查是否已创建并保存了训练好的模型文件。如果模型文件存在,则加载它。否则,调用train_model()方法。
-
train_model()方法创建了一个随机森林分类器,该分类器首先通过 5 折交叉验证程序进行评估,以验证其泛化能力:
classifier = RandomForestClassifier( random_state=self.randomSeed) kfold = model_selection.KFold(n_splits=NUM_FOLDS) cv_results = model_selection.cross_val_score( classifier, X, y, cv=kfold, scoring='accuracy') print(f"Model's Mean k-fold accuracy = {cv_results.mean()}") -
接下来,使用整个数据集训练模型并对其进行评估:
classifier.fit(X, y) y_pred = classifier.predict(X) print(f"Model's Training Accuracy = {accuracy_score(y, y_pred)}") -
一旦训练完成,随机森林 模型可以为数据集的各个属性分配 特征重要性 值,表示每个属性对模型预测的贡献。虽然这些值提供了对影响模型决策的因素的见解,但我们将在这里使用它们来验证我们假设的四个属性是否能产生不同的结果:
feature_importances = dict(zip(X.columns, classifier.feature_importances_)) print(dict(sorted(feature_importances.items(), key=lambda item: -item[1]))) -
is_credit_risk() 方法利用训练过的模型,通过 Scikit-learn 的 predict() 方法预测给定申请者数据的结果,当候选者被认为是信用风险时返回 True。
-
此外,risk_probability() 方法提供一个介于 0 和 1 之间的浮动值,表示申请者被认为是信用风险的程度。它利用模型的 predict_proba() 方法,在应用阈值将其转换为离散值 0 或 1 之前,捕获连续的输出值。
-
方便的方法 get_applicant() 允许我们从数据集中选择一个申请者的行并打印其数据。
-
最后,main() 函数首先通过创建 CreditRiskData 类的实例来启动,如果需要,它会第一次训练模型。接着,它从数据集中获取第 25 个申请者的信息并打印出来。之后,它修改四个可变属性的值,并打印修改后的申请者信息。
-
当第一次执行 main() 函数时,交叉验证测试评估的结果,以及训练精度,将会被打印出来:
Loading the dataset... Model's Mean k-fold accuracy = 0.7620000000000001 Model's Training Accuracy = 1.0这些结果表明,虽然训练过的模型能够完全再现数据集的结果,但在对未见过的样本进行预测时,模型的准确率约为 76%——对于这个数据集来说,这是一个合理的结果。
-
接下来,特征重要性值按降序打印。值得注意的是,列表中的前几个属性就是我们选择修改的四个:
------- Feature Importance values: { "amount": 0.2357488244229738, "duration": 0.15326057481242433, "checking": 0.1323559111404014, "employment_duration": 0.08332785367394725, "credit_history": 0.07824885834794511, "savings": 0.06956484835261427, "present_residence": 0.06271797270697153, … } -
数据集中第 25 行申请者的属性信息和预测结果现在被打印出来。值得注意的是,在文件中,这对应于第 27 行,因为第一行包含标题,数据行从 0 开始计数:
applicant = credit_data.get_applicant(25)输出结果如下:
Before modifications: ------------- Applicant 25: checking 1 duration 6 credit_history 1 amount 1374 savings 1 employment_duration 2 present_residence 2 … => Credit risk = True如输出所示,该申请者被认为是信用风险。
-
程序现在通过 with_values() 方法修改所有四个值:
modified_applicant = applicant.with_values([1000, 20, 2, 0])然后,它重复打印,反映变化:
After modifications: ------------- Applicant 25: checking 2 duration 20 credit_history 1 amount 1000 savings 0 employment_duration 2 present_residence 2 … => Credit risk = False
正如前面的输出所示,当使用新值时,申请者不再被认为是信用风险。虽然这些修改的值是通过反复试验手动选择的,但现在是时候使用遗传算法来自动化这个过程了。
使用遗传算法进行反事实分析
为了演示遗传算法如何与反事实一起工作,我们将从第 25 行的相同申请者开始,该申请者最初被认为是信用风险,然后寻找使其预测信用可接受的最小变化集。如前所述,我们将考虑对amount、duration、checking和savings属性进行更改。
解的表示
在处理这个问题时,表示候选解的一种简单方法是使用一个包含四个整数值的列表,每个值对应我们希望修改的四个属性之一:
[amount, duration, checking, savings]
例如,我们在credit_risk_data.py程序的主函数中使用的修改值将表示如下:
[1000, 20, 2, 0]
正如我们在前几章中所做的那样,我们将利用浮点数来表示整数。这样,遗传算法可以使用行之有效的实数操作符进行交叉和变异,并且无论每个项的范围如何,都使用相同的表示。在评估之前,实数将通过int()函数转换为整数。
我们将在下一小节中评估每个解。
评估解
由于我们的目标是找到使预测结果反转的最小变化程度,因此一个问题出现了:我们如何衡量所做变化的程度?一种可能的方法是使用当前解的值与原始值之间的绝对差值之和,每个差值除以该值的范围,如下所示:
∑ i=1 4 |current valu e i − original valu e i| _______________________ range of valu e i
现在我们已经建立了候选解的表示和评估方法,我们准备展示遗传算法的 Python 实现。
遗传算法解
基于遗传算法的反事实搜索实现于名为01_counterfactual_search.py的 Python 程序中,该程序位于github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_12/01_counterfactual_search.py。
以下步骤描述了该程序的主要部分:
-
我们首先定义几个常量。然后,我们创建CreditRiskData类的一个实例:
credit_data = CreditRiskData(randomSeed=RANDOM_SEED) -
接下来的几个代码片段用于设置将用作解变量的四个属性的范围。我们首先声明占位符,如下所示:
bounds_low = [] bounds_high = [] ranges = []第一个列表包含四个属性的下限,第二个包含上限,第三个包含它们之间的差值。
-
接下来是set_ranges()方法,该方法接受四个属性的上下限,并相应地填充占位符。由于我们使用的是将转换为整数的实数,我们将增加每个范围的值,以确保结果整数的均匀分布:
bounds_low = [amount_low, duration_low, checking_low, savings_low] bounds_high = [amount_high, duration_high, checking_high, savings_high] bounds_high = [high + 1 for high in bounds_high] ranges = [high - low for high, low in zip(bounds_high, bounds_low)] -
然后,我们将使用set_ranges()方法为当前问题设置范围。我们选择了以下值:
-
amount:100..5000
-
duration:2..72
-
checking:0..3
-
savings:0..4:
bounds_low, bounds_high, ranges = set_ranges(100, 5000, 2, 72, 0, 3, 0, 4)
-
-
现在,我们必须从数据集的第 25 行选择申请人(与之前使用的一样),并将其原始的四个值保存在单独的变量applicant_values中:
applicant = credit_data.get_applicant(25) applicant_values = applicant.get_values() -
get_score()函数用于通过计算需要最小化的代价来评估每个解决方案的适应度。代价由两部分组成:首先,如评估解决方案部分所述,我们计算该解决方案表示的四个属性值与候选人匹配的原始值之间的距离——距离越大,代价越大:
cost = sum( [ abs(int(individual[i]) - applicant_values[i])/ranges[i] for i in range(NUM_OF_PARAMS) ] ) -
由于我们希望解决方案能够代表一个有信用的候选人,因此代价的第二部分(可选)用于惩罚被视为信用风险的解决方案。在这里,我们将使用is_credit_risk()和risk_probability()方法,这样当前者表明解决方案没有信用时,后者将用于确定增加的惩罚程度:
if credit_data.is_credit_risk( applicant.with_values(individual) ): cost += PENALTY * credit_data.risk_probability( applicant.with_values(individual)) -
该程序的其余部分与我们之前看到的非常相似,当时我们使用实数列表来表示个体——例如,第九章,深度学习网络架构优化。我们将开始使用单目标策略来最小化适应度,因为我们的目标是最小化由先前定义的代价函数计算出的值:
creator.create("FitnessMin", base.Fitness, weights=(-1.0,)) -
由于解决方案由四个浮动值的列表表示,每个值对应我们可以修改的一个属性,并且每个属性有其自己的范围,我们必须为它们定义独立的工具箱creator操作符,使用相应的bounds_low和bounds_high值:
toolbox.register("amount", random.uniform, \ bounds_low[0], bounds_high[0]) toolbox.register("duration", random.uniform, \ bounds_low[1], bounds_high[1]) toolbox.register("checking", random.uniform, \ bounds_low[2], bounds_high[2]) toolbox.register("savings", random.uniform, \ bounds_low[3], bounds_high[3]) -
这四个操作符接着在individualCreator的定义中使用:
toolbox.register("individualCreator", tools.initCycle, creator.Individual, (toolbox.amount, toolbox.duration, toolbox.checking, toolbox.savings), n=1) -
在将selection操作符分配给通常的tournament selection(锦标赛选择),并设置锦标赛大小为2后,我们将为其分配crossovers和mutation操作符,这些操作符专门用于有界浮点数列表染色体,并提供我们之前定义的范围:
toolbox.register("select", tools.selTournament, tournsize=2) toolbox.register("mate", tools.cxSimulatedBinaryBounded, low=bounds_low, up= bounds_high, eta=CROWDING_FACTOR) toolbox.register("mutate", tools.mutPolynomialBounded, low= bounds_low, up=bounds_high, eta=CROWDING_FACTOR, indpb=1.0 / NUM_OF_PARAMS) -
此外,我们将继续使用elitist方法,其中hall-of-fame(HOF)成员——当前最佳个体——始终不加修改地传递到下一代:
population, logbook = elitism.eaSimpleWithElitism( population, toolbox, cxpb=P_CROSSOVER, mutpb=P_MUTATION, ngen=MAX_GENERATIONS, stats=stats, halloffame=hof, verbose=True)
最后,我们打印出找到的最佳解决方案以及该解决方案的预测值。
现在是时候试用该程序并查看结果了!输出从打印所选申请人的原始属性和状态开始:
Loading the dataset...
Applicant 25:
checking 1
duration 6
credit_history 1
amount 1374
savings 1
employment_duration 2
present_residence 2
...
=> Credit risk = amount, duration, checking, and savings:
gen nevals min avg
0 50 0.450063 51.7213
1 42 0.450063 30.2695
2 44 0.393725 14.2223
3 37 0.38311 7.62647
...
28 40 0.141661 0.169646
29 40 0.141661 0.175401
30 44 0.141661 0.172197
-- 最佳方案:金额 = 1370,期限 = 16,检查 = 1,储蓄 = 1
-- 预测:是 _ 风险 = 检查和储蓄账户无需更改。
尽管金额被调整为1,374,它本来可以保持不变为 1374。通过直接调用is_credit_risk()函数,并使用值[1374, 16, 1, 1],可以验证这一点。根据我们的成本函数定义,1370 与 1374 之间的差异除以 4900 的范围是微小的,可能导致算法需要更多的世代才能识别出 1374 比 1370 更好。通过将金额的范围缩小到 1000..2000,同样的程序能够在规定的 30 代内很好地识别出 1374 的值。
更多的“假设”场景
我们发现,将申请人 25 的贷款期限从 6 个月调整为 16 个月,可以使得申请获得信用。但是,如果申请人希望更短的期限,或想最大化贷款金额呢?这些正是反事实所探索的“假设”场景,我们编写的代码可以模拟不同场景并解决这些问题,如下文小节所示。
减少期限
让我们从同一申请人希望将期限设置为比之前找到的 16 个月更短的情况开始——其他变化能否弥补这一点?
根据前次运行的经验,缩小四个属性的允许范围可能是有益的。让我们尝试采取更保守的方式,并使用以下范围:
-
金额: 1000..2000
-
期限: 2..12
-
检查: 0..1
-
储蓄: 0..1
在这里,我们将期限限制为 12 个月,并且力求避免增加当前检查或储蓄账户的余额。这可以通过修改set_ranges()的调用来实现,如下所示:
bounds_low, bounds_high, ranges = set_ranges(1000, 2000, 2, 12, 0, 1, 0, 1)
当我们运行修改后的程序时,结果如下:
-- Best solution: Amount = 1249, Duration = 12, checking = 1, savings = 1
-- Prediction: is_risk = False
这表明,如果申请人愿意稍微降低所请求的贷款金额,则可以实现 12 个月的缩短期限。
如果我们想进一步减少期限呢?比如将期限范围更改为 1..10。这将得到以下结果:
-- Best solution: Amount = 1003, Duration = 10, checking = 1, savings = 0
-- Prediction: is_risk = True
这表明,算法未能在这些范围内找到一个申请人是可信贷的解决方案。请注意,这并不一定意味着不存在这样的解决方案,但似乎不太可能。
即使我们回去并允许checking和savings账户的原始范围(0..3 和 0..4),如果期限限制在 10 个月或更少,仍然找不到解决方案。然而,允许金额低于 1,000 似乎能解决问题。让我们使用以下范围:
-
金额: 100..2000
-
期限: 2..10
-
checking: 0..1
-
储蓄: 0..1
在这里,我们得到了以下解决方案:
-- Best solution: Amount = 971, Duration = 10, checking = 1, savings = 0
-- Prediction: is_risk = False
这意味着,如果申请者将贷款金额减少到 971,则申请将按所需的 10 个月期限获得批准。
更令人惊讶的是,savings属性的值为 0,低于原始的 1。如你所记得,这个属性的值解释如下:
-
0: 未知/没有储蓄账户
-
1: 余额 < 100 DM
-
2: 100 <= 余额 < 500 DM
-
3: 500 <= 余额 < 1000 DM
-
4: 余额 >= 1000 DM
看起来,在申请贷款时没有储蓄是不利的。而且,如果我们尝试所有除 0 以外的可能值,将范围设置为 1..3,则没有找到解决方案。这表明,根据使用的模型,没有储蓄账户比拥有储蓄账户更为优越,即使储蓄余额较高。这可能是模型存在缺陷的表现,或者是数据集本身存在问题,例如数据偏见或不完整。这样的发现是反事实推理的一种使用场景。
最大化贷款金额
到目前为止,我们已经操作了一个最初被认为是信用风险的申请者的结果。然而,我们可以对任何申请者进行这种“假设”游戏,包括那些已经被批准的申请者。让我们考虑第 68 行的申请者(文件中的第 70 行)。当打印出申请者信息时,我们看到以下内容:
Applicant 68:
checking 0
duration 36
credit_history 1
amount 1819
savings 1
employment_duration 2
present_residence 4
...
=> Credit risk = 02_counterfactual_search.py, which is located at https://github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_12/02_counterfactual_search.py.
This program is identical to the previous one, except for three small changes. The first change is the use of this particular applicant:
applicant = credit_data.get_applicant(68)
The second change is to the range values:
bounds_low, bounds_high, ranges = set_ranges(2000, 50000, 36, 36, 0,
0, 1, 1)
The amount range is modified to allow up to a sum of 50,000, while the other ranges have been fixed to the existing values of the candidate. This will enable the genetic algorithm to only modify the amount.
But how do we instruct the genetic algorithm to *maximize* the loan amount? As you may recall, the cost function was initially designed to minimize the distance between the modified individual and the original one within the given range. However, in this scenario, we want the loan amount to be as large as possible compared to the original amount. One approach to address this is to replace the cost function with a new one. However, we’ll explore a somewhat simpler solution: we’ll set the original loan amount value to the same value we use for the upper end of the range, which is 50,000 in this case. By doing this, when the algorithm aims to find the closest possible solution, it will work inherently to maximize the amount toward this upper limit. This can be done by adding a single line of code that overrides the original amount value of the applicant. The line is placed immediately following the one that stores the original attribute values to be used by the cost function:
applicant_values = applicant.get_values()
applicant_values[0] 被使用,因为金额属性是程序所用的四个值中的第一个。
运行这个程序会得到以下输出:
-- Best solution: Amount = 14165, Duration = 36, checking = 0, savings = 1
-- Prediction: is_risk = False
上述输出表明,这位申请者在增加贷款金额的同时,保持了原有的信用良好状态,而无需对其他属性做出任何更改。
接下来的问题是,是否可以通过允许更改checking和/或savings属性,进一步增加贷款金额。为此,我们将修改边界,使这两个属性可以调整为任何有效值:
bounds_low, bounds_high, ranges = set_ranges(2000, 50000, 36, 36, 0,
3, 0, 4)
修改后的程序结果有些令人惊讶:
-- Best solution: Amount = 50000, Duration = 36, checking = 1, savings = 1
-- Prediction: is_risk = False
这个结果表明,将支票账户的状态从 0(没有支票账户)改为 1(余额<100 DM)就足以获得显著更高的贷款金额。如果我们用更高的金额(例如 500,000,替换程序中两个不同位置的值)重复这个实验,结果将类似——只要将支票状态从 0 改为 1,候选人就能获得这笔高额贷款。
这个观察结果同样适用于其他申请人,表明模型可能存在潜在的漏洞。
鼓励你对程序进行额外修改,探索自己的“如果…会怎样”场景。除了提供有价值的洞察和更深入地理解模型行为外,实验“如果…会怎样”场景还可以非常有趣!
扩展到其他数据集
本章演示的相同过程可以应用于其他数据集。例如,考虑一个用于预测租赁公寓预期价格的数据集。在这个场景中,你可以使用类似的反事实分析来确定房东可以对公寓进行哪些修改,以实现某个租金。使用类似本章介绍的程序,你可以应用遗传算法来探索输入特征变化对模型预测的敏感性,并确定可行的洞察,以实现期望的结果。
总结
在本章中,我们介绍了XAI、因果关系和反事实的概念。在熟悉了German Credis Risk 数据集后,我们创建了一个机器学习模型,用于预测申请人是否具备信用资格。接下来,我们应用了基于遗传算法的反事实分析,将该数据集应用于训练好的模型,探索了几个“如果…会怎样”场景,并获得了宝贵的洞察。
在接下来的两章中,我们将转向加速基于遗传算法的程序的执行,例如我们在本书中开发的程序,通过探索应用并发的不同策略。
进一步阅读
如需了解本章中涉及的更多内容,请参考以下资源:
-
Python 中的可解释 AI(XAI)实践,作者:Denis Rothman,2020 年 7 月
-
Python 中的因果推理与发现,作者:Aleksander Molak,2023 年 5 月
-
企业中的负责任 AI,作者:Adnan Masood,2023 年 7 月
-
Scikit-learn RandomForestClassifier:
scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestClassifier.html
第四部分:通过并发和云策略提升性能
本部分重点介绍通过先进的编程技术——特别是并发和云计算——提升遗传算法的性能。第一章介绍了并发,特别是多进程,作为提升遗传算法效率的工具。通过将多种多进程方法应用于一个计算密集型的 One-Max 问题版本,展示了显著的性能提升。接下来的一章则转向客户端-服务器模型,将遗传算法划分为异步客户端操作和服务器端适应度函数计算。该模型通过 Flask 实现服务器端,使用 Python 的 asyncio 实现客户端,最终部署到 AWS Lambda 云端。
本部分包含以下章节:
-
第十三章**,加速遗传算法:并发的力量
-
第十四章**,超越本地资源:在云端扩展遗传算法
第十三章:加速遗传算法——并发的力量
本章深入探讨了如何通过并发,特别是多进程,来提升遗传算法的性能。我们将探索 Python 内置的功能以及外部库来实现这一改进。
本章首先强调了将 并发 应用于遗传算法的潜在好处。接着,我们通过尝试各种 多进程 方法来解决计算密集型的 One-Max 问题,将这一理论付诸实践。这使我们能够衡量通过这些技术实现的性能提升程度。
本章结束时,你将能够做到以下几点:
-
了解为什么遗传算法可能计算密集且耗时
-
认识到为什么遗传算法非常适合并发执行
-
实现一个计算密集型的 One-Max 问题版本,我们之前已经探索过
-
学习如何使用 Python 内置的多进程模块加速遗传算法的过程
-
熟悉 SCOOP 库,学习如何将其与 DEAP 框架结合使用,进一步提高遗传算法的效率
-
尝试两种方法,深入了解如何将多进程应用于当前问题
技术要求
在本章中,我们将使用 Python 3 并配合以下支持库:
-
deap
-
numpy
-
scoop —— 本章介绍
重要提示
如果你使用我们提供的 requirements.txt 文件(见 第三章),这些库已经包含在你的环境中了。
本章中将使用的程序可以在本书的 GitHub 仓库中找到,链接如下:github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/tree/main/chapter_13。
查看以下视频,了解代码的实际运行:
实际应用中遗传算法的长时间运行
到目前为止,我们探讨的示例程序虽然解决了实际问题,但它们故意设计得可以迅速收敛到一个合理的解。然而,在实际应用中,由于遗传算法的工作方式——通过考虑多样化的潜在解决方案来探索解空间——它通常会非常耗时。影响典型遗传算法运行时间的主要因素如下:
-
世代数:遗传算法通过一系列世代来运行,每一代都涉及对种群的评估、选择和操作。
-
种群大小:遗传算法保持一个潜在解的种群;更复杂的问题通常需要更大的种群。这增加了每一代中需要评估、选择和操作的个体数量。
-
适应度评估:必须评估种群中每个个体的适应度,以确定其解决问题的效果。根据适应度函数的复杂性或优化问题的性质,评估过程可能既计算密集又耗时。
-
遗传操作:选择用于选择作为每代父母的个体对。交叉和变异应用于这些个体对,并且根据算法的设计,可能会计算密集,特别是在处理复杂数据结构时。然而,在实践中,适应度函数的持续时间通常是每个个体所消耗时间的主导因素。
减少遗传算法长时间运行的一个显而易见的方式是使用并行化,正如我们将在以下小节中进一步探讨的。
并行化遗传算法
在单一代际内,遗传算法可以被认为是显然可并行化的——它们可以轻松地分解为多个独立任务,这些任务之间几乎没有或完全没有依赖关系或交互。这是因为种群中个体的适应度评估和操作通常是独立的任务。每个个体的适应度是根据其自身特征评估的,而遗传操作符(交叉和变异)是独立地应用于每一对个体的。这种独立性使得这些任务能够轻松并行执行。
有两种并行化方法——多线程和多进程——是我们将在以下小节中探讨的内容。
多线程
多线程是一种并发执行模型,允许多个线程在同一进程内存在,共享相同的资源,如内存空间,但独立运行。每个线程代表一个独立的控制流,使程序能够并发执行多个任务。
在多线程环境中,线程可以被看作是共享相同地址空间的轻量级进程。多线程特别适用于可以分解为较小、独立工作单元的任务,使得可用资源的使用更加高效,并增强响应性。以下图示了这一点:

图 13.1:多个线程在单一进程内并发运行
然而,Python 中的多线程面临一些限制,这些限制影响了它在我们用例中的效果。一个主要因素是全局解释器锁(GIL),这是 CPython(Python 的标准实现)中的一个关键部分。GIL 是一个互斥锁(mutex),用于保护对 Python 对象的访问,防止多个本地线程同时执行 Python 字节码。因此,多线程的好处主要体现在 I/O 密集型任务中,正如我们将在下一章中探讨的那样。对于那些计算密集型任务,这些任务不经常释放 GIL,且在许多数值计算中比较常见,多线程可能无法提供预期的性能提升。
注意
Python 社区的讨论和持续的研究表明,GIL 带来的限制可能会在未来的 Python 版本中解除,从而提高多线程的效率。
幸运的是,接下来描述的方法是一个非常可行的选择。
多进程
多进程是一种并发计算范式,涉及计算机系统内多个进程的同时执行。与多线程不同,多进程允许创建独立的进程,每个进程都有独立的内存空间。这些进程可以在不同的 CPU 核心或处理器上并行运行,使其成为一种强大的并行化任务的技术,能够充分利用现代多核架构,如下图所示:

图 13.2:多个进程在独立的核心上同时运行
每个进程独立运行,避免了与共享内存模型相关的限制,例如 Python 中的全局解释器锁(GIL)。多进程对于 CPU 密集型任务尤其有效,这类任务在遗传算法中常见,其中计算工作负载可以被划分为可并行化的单元。
由于多进程似乎是一种提高遗传算法性能的可行方法,我们将在本章剩余部分探讨其实现,使用 OneMax 问题的新版本作为我们的基准。
回到 OneMax 问题
在第三章,《使用 DEAP 框架》中,我们使用了 OneMax 问题作为遗传算法的“Hello World”。简要回顾一下,目标是发现一个指定长度的二进制字符串,使其数字之和最大。例如,在处理一个长度为 5 的 OneMax 问题时,考虑到的候选解包括 10010(数字之和=2)和 01110(数字之和=3),最终的最优解是 11111(数字之和=5)。
在第三章中,我们使用了问题长度为 100、种群大小为 200、50 代的参数,而在这里我们将处理一个大幅缩小的版本,问题长度为 10、种群大小为 20,且只有 5 代。这一调整的原因很快就会显现出来。
一个基准程序
该 Python 程序的初始版本为01_one_max_start.py,可在github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_13/01_one_max_start.py找到。
该程序的主要功能概述如下:
-
候选解通过一个由 0 和 1 组成的整数列表来表示。
-
oneMaxFitness()函数通过对列表元素求和来计算适应度:
def oneMaxFitness(individual): return sum(individual), # return a tuple toolbox.register("evaluate", oneMaxFitness) -
对于遗传操作,我们采用锦标赛选择(锦标赛大小为 4)、单点交叉和翻转位变异。
-
采用了精英主义方法,利用elitism.eaSimpleWithElitism()函数。
-
程序的运行时间通过调用time.time()函数来测量,包围着main()函数的调用:
if __name__ == "__main__": start = time.time() main() end = time.time() print(f"Elapsed time = {(end - start):.2f} seconds")
运行该程序会产生以下输出:
gen nevals max avg
0 20 7 4.35
1 14 7 6.1
2 16 9 6.85
3 16 9 7.6
4 16 9 8.45
5 13 10 8.9
Best Individual = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
Elapsed time = 0.00 seconds
输出表明程序在第 5 代时达到了最优解 1111111111,运行时间不到 10 毫秒(仅考虑经过时间的小数点后两位)。
另一个值得注意的细节,这将在后续实验中起作用,是每代进行的适应度评估次数。相关值可以在从左数第二列nevals中找到。尽管种群大小为 20,每代的评估次数通常少于 20 次。这是因为如果某个个体的适应度已被计算过,算法会跳过该适应度函数。将这一列的数值加起来,我们可以发现,在程序运行过程中执行的总适应度评估次数为 95 次。
模拟计算密集度
如前所述,遗传算法中最消耗计算资源的任务通常是对个体的适应度评估。为了模拟这一方面,我们将故意延长适应度函数的执行时间。
该修改已在 Python 程序02_one_max_busy.py中实现,程序可在github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_13/02_one_max_busy.py找到。
该程序基于之前的版本,进行了如下修改:
-
添加了一个busy_wait()函数。该函数通过执行一个空循环来消耗指定时长(以秒为单位):
def busy_wait(duration): current_time = time.time() while (time.time() < current_time + duration): pass -
更新原始适应度函数,以便在计算数字之和之前调用busy_wait()函数:
def oneMaxFitness(individual): busy_wait(DELAY_SECONDS) return sum(individual), # return a tuple -
添加了DELAY_SECONDS常量,并将其值设置为 3:
DELAY_SECONDS = 3
运行修改后的程序会产生以下输出:
gen nevals max avg
0 20 7 4.35
1 14 7 6.1
2 16 9 6.85
3 16 9 7.6
4 16 9 8.45
5 13 10 8.9
Best Individual = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
Elapsed time = 285.01 seconds
正如预期的那样,修改后的程序输出与原始程序相同,唯一显著的不同是经过的时间显著增加,约为 285 秒。
这个持续时间是完全合理的;正如前面部分所强调的那样,在程序的执行过程中有 95 次适应度函数的调用(nevals列中的值之和)。由于每次执行现在需要额外的 3 秒,因此预期的额外时间为 95 次乘以 3 秒,总计 285 秒。
在检查这些结果时,让我们也确定一下理论上的限制,或者我们可以追求的最佳情况。如输出所示,执行基准遗传算法涉及六个“轮次”的适应度计算——一次用于初始代(“代零”),另外五次用于随后的五代。在每代内完全并发的情况下,最佳的执行时间为 3 秒,即单次适应度评估的时间。因此,理论上我们可以达到的最佳结果是 18 秒,即 6 次乘以每轮 3 秒。
以这个理论上的限制为基础,我们现在可以继续探索将多进程应用到基准测试中的方法。
使用 Pool 类进行多进程
在 Python 中,multiprocessing.Pool模块提供了一种方便的机制,可以将操作并行化到多个进程中。通过Pool类,可以创建一组工作进程,并将任务分配给这些进程。
Pool类通过提供map和apply方法来抽象化管理单个进程的细节。相反,DEAP 框架使得利用这种抽象变得非常简单。toolbox模块中指定的所有操作都通过默认的map函数在内部执行。将这个map替换为Pool类中的map意味着这些操作,包括适应度评估,现在将分配到池中的工作进程上。
让我们通过将多进程应用到之前的程序来进行说明。此修改在03_one_max_pool.py Python 程序中实现,可以在github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_13/03_one_max_pool.py找到。
只需进行少量修改,具体如下:
-
导入multiprocessing模块:
import multiprocessing -
multiprocessing.Pool类实例的map方法被注册为 DEAP 工具箱模块中使用的map函数:
toolbox.register("map", pool.map) -
遗传算法流程,实现在main()函数中,现在在with语句下运行,该语句管理multiprocessing.Pool实例的创建和清理:
def main(): with multiprocessing.Pool() as pool: toolbox.register("map", pool.map) # create initial population (generation 0): population = toolbox.populationCreator( n=POPULATION_SIZE) ...
在四核计算机上运行修改后的程序,输出结果如下:
gen nevals max avg
0 20 7 4.35
1 14 7 6.1
2 16 9 6.85
3 16 9 7.6
4 16 9 8.45
5 13 10 8.9
Best Individual = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
Elapsed time = 78.49 seconds
如预期的那样,输出结果与原始程序相同,而运行时间明显比之前短。
重要提示
该程序的运行时间可能会因不同计算机之间的差异而有所不同,甚至在同一台机器上的连续运行之间也会有所变化。如前所述,此基准测试的最佳结果大约是 18 秒。如果您的计算机已经接近这个理论极限,您可以通过将种群大小加倍(或根据需要更多)来使基准程序变得更加 CPU 密集型。记得调整本章和下一章中的所有基准程序版本,以反映新的种群大小。
假设使用四核计算机,您可能期望运行时间是之前的四分之一。然而,在这种情况下,我们可以看到持续时间的比率大约是 3.6(≈285/79),低于预期的 4。
有几个因素导致我们没有完全实现节省时间的潜力。其中一个重要因素是与并行化过程相关的开销,在将任务分配给多个进程并协调它们的执行时,会引入额外的计算负担。
此外,任务的粒度也起着作用。虽然适应度函数消耗了大部分处理时间,但像交叉和变异等遗传操作可能会遇到并行化开销大于收益的情况。
此外,算法中的某些部分,如处理名人堂和计算统计数据,并没有并行化。这一限制限制了并行化可以发挥的程度。
为了说明最后一点,我们来看一下程序运行时,Mac 上 Activity Monitor 应用程序的快照:

图 13.3:Activity Monitor 展示四个遗传算法进程在运行
正如预期的那样,处理多处理器程序的四个 Python 进程得到了大量利用,尽管还没有达到 100%。这引出了一个问题,能否“榨取”更多 CPU 的潜力,进一步缩短程序的运行时间?在接下来的部分中,我们将探讨这一可能性。
增加进程数
由于我们手头的四个 CPU 并未被充分利用,问题随之而来:是否可以通过使用超过四个并发进程来进一步提高利用率?
当我们通过调用 multiprocessing.Pool() 创建 Pool 类的实例时,如果没有任何参数,默认创建的进程数将与可用 CPU 的数量相同——在我们的案例中是四个。然而,我们可以使用可选的 processes 参数来设置所需的进程数量,如下所示:
multiprocessing.Pool(processes=<number of processes>)
在我们的下一个实验中,我们将利用这个选项来改变进程数量,并比较结果时长。这将在 Python 程序 04_one_max_pool_loop.py 中实现,程序可通过 github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_13/04_one_max_pool_loop.py 获取。
这个程序对前一个程序做了一些修改,具体如下:
-
main() 函数被重命名为 run(),因为我们将多次调用它。它现在接受一个参数,num_processes。
-
Pool 对象的实例化会传递此参数,以创建所请求大小的进程池:
with multiprocessing.Pool(processes=num_processes) as pool: -
plot_graph() 函数被添加用来帮助展示结果。
-
程序的启动代码位于文件底部,现在创建了一个循环,多次调用 run() 函数,num_processes 参数从 1 增加到 20。它还将结果时长收集到列表 run_times 中:
run_times = [] for num_processes in range(1, 21): start = time.time() run(num_processes) end = time.time() run_time = end – start run_times.append(run_time) -
在循环结束时,利用 run_times 列表中的值绘制两个图表,借助 plot_graph() 函数:
plot_graph(1, run_times, "Number of Processes", "Run Time (seconds)", hr=33) plot_graph(2, [1/rt for rt in run_times], "Number of Processes", "(1 / Run Time)", "orange")
在我们继续描述这个实验的结果之前,请记住,实际的数值可能因不同的计算机而有所不同。因此,您的具体结果可能会有所不同。然而,主要的观察结果应该是成立的。
在我们的四核计算机上运行此程序将产生以下输出:
num_processes = 1
gen nevals max avg
0 20 7 4.35
1 14 7 6.1
2 16 9 6.85
3 16 9 7.6
4 16 9 8.45
5 13 10 8.9
Best Individual = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
Number of Processes = 1 => Run time = 286.62 seconds
num_processes = 2
gen nevals max avg
0 20 7 4.35
1 14 7 6.1
2 16 9 6.85
3 16 9 7.6
4 16 9 8.45
5 13 10 8.9
Best Individual = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
Number of Processes = 2 => Run time = 151.75 seconds
...
num_processes = 20
gen nevals max avg
0 20 7 4.35
1 14 7 6.1
2 16 9 6.85
3 16 9 7.6
4 16 9 8.45
5 13 10 8.9
Best Individual = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
Number of Processes = 20 => Run time = 33.30 seconds
此外,还生成了两个图表。第一个图表,如下图所示,展示了不同进程数量下程序的运行时间。正如预期的那样,随着进程数量的增加,运行时间持续减少,超出了四个可用 CPU 的容量。值得注意的是,超过八个进程后,性能提升变得非常有限:

图 13.4:程序在不同进程数量下的运行时长
图中虚线红线表示我们测试中取得的最短时长——约 31 秒。为了进行对比,我们回顾一下本次测试的理论极限:在每轮 3 秒的适应度计算中,6 轮的计算总时间最好的结果是 18 秒。
第二个图表,如下图所示,描绘了时长的倒数(即 1/时长),表示程序在不同进程数量下的“速度”:

图 13.5:程序在不同进程数下的运行时长
这张图表明,程序的速度随着进程数增加到 8 个时几乎呈线性增长,但超过这个点后,增长速率减缓。值得注意的是,图表显示在从 15 个进程增加到 16 个进程时,性能显著提升,这一趋势在之前的图表中也有所体现。
当进程数超过可用的物理 CPU 核心数时,所观察到的性能提升现象,称为“过度订阅”,可以与多种因素相关。这些因素包括任务重叠、I/O 和等待时间、多线程、超线程以及操作系统的优化。从 15 个进程到 16 个进程的显著性能提升可能受计算机硬件架构和操作系统进程调度策略的影响。此外,程序工作负载的特定结构,也由三分之二的适应度计算轮次中涉及正好 16 次适应度评估(如nevals列所示)可见,也可能有助于这种增加。需要注意的是,这些效果会因计算机架构和所解决问题的性质而有所不同。
这个实验的主要收获是,实验不同的进程数以找到程序的最佳配置非常重要。幸运的是,你不需要每次都重新运行整个遗传算法——几代就足够用来比较并找出最佳设置。
使用 SCOOP 库进行多进程处理
另一种引入多进程的方法是使用SCOOP,这是一个旨在将代码执行并行化和分布到多个进程的 Python 库。SCOOP,即Python 中的简单并发操作,为 Python 中的并行计算提供了一个简单的接口,我们稍后会详细探讨。
将 SCOOP 应用到基于 DEAP 的程序中与使用multiprocessing.Pool模块非常相似,如 Python 程序05_one_max_scoop.py所示,可以在github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_13/05_one_max_scoop.py查看。
这个程序只需要对原始的非多进程版本02_one_max_busy.py进行几个修改;这些修改在此列出:
-
导入 SCOOP 的futures模块:
from scoop import futures -
将 SCOOP 的futures模块的map方法注册为 DEAP 工具箱模块中使用的“map”函数:
toolbox.register("map", futures.map)
就这样!但是,启动这个程序需要通过以下命令使用 SCOOP 作为主模块:
python3 -m scoop 05_one_max_scoop.py
在同一台四核计算机上运行该程序,得到如下输出:
SCOOP 0.7 2.0 on darwin using Python 3.11.1
Deploying 4 worker(s) over 1 host(s).
Worker distribution:
127.0.0.1: 3 + origin
Launching 4 worker(s) using /bin/zsh.
gen nevals max avg
0 20 7 4.35
1 14 7 6.1
2 16 9 6.85
3 16 9 7.6
4 16 9 8.45
5 13 10 8.9
Best Individual = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
03_one_max_pool.py program, as both programs employed four concurrent processes.
However, we have seen that “oversubscription” (i.e., using more concurrent processes than the number of available cores) could yield better results. Luckily, SCOOP enables us to control the number of processes, or “workers,” via a command-line argument. Let’s run the program again but, this time, use 16 workers:
python3 -m scoop -n 16 05_one_max_scoop.py
The resulting output is as follows:
SCOOP 0.7 2.0 在 darwin 上使用 Python 3.11.1
在 1 台主机上部署 16 个工作进程。
工作进程分配:
127.0.0.1: 15 + 原点
使用 /bin/zsh 启动 16 个工作进程。
gen nevals max avg
0 20 7 4.35
1 14 7 6.1
2 16 9 6.85
3 16 9 7.6
4 16 9 8.45
5 13 10 8.9
最佳个体 = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
忘记了未来的进度或接收到了意外的未来。这些警告表示通信问题,通常与由于过度订阅而导致的资源限制有关。尽管有这些警告,SCOOP 通常能够恢复并成功地重现预期的结果。
一些实验表明,当使用 20 个以上进程时,SCOOP 可以在 20 秒内完成任务,相较于我们在相同问题上使用 multiprocessing.Pool 模块时达到的 31 秒,取得了显著的提升。
这个改进可能源自于 SCOOP 在并行化方面的独特方法。例如,其动态任务分配可能比 multiprocessing.Pool 使用的静态方法更有效。此外,SCOOP 可能在进程管理开销上表现得更高效,且在可用核心上调度任务的能力更强。然而,这并不意味着 SCOOP 总是会优于 multiprocessing.Pool。建议试用两种方法,看看它们在你特定算法和问题上的表现。好消息是,在两者之间切换相对简单。
话虽如此,值得一提的是,SCOOP 提供了一个使其与 multiprocessing.Pool 区别开来的关键特性——分布式计算。这一特性允许在多台机器上进行并行处理。我们将在接下来的部分简要探讨这一功能。
使用 SCOOP 进行分布式计算
SCOOP 不仅支持单机上的多进程计算,还能够在多个互联的节点之间进行分布式计算。这一功能可以通过两种方式进行配置:
-
使用 --hostfile 参数:此参数后面应跟随一个包含主机列表的文件名。该文件中每行的格式为<主机名或 IP 地址> <进程数>,其中每一行指定了一个主机及该主机上要运行的进程数。
-
使用 --hosts 参数:此选项需要一个主机名列表。每个主机名应根据你打算在该主机上运行的进程数量列出。
如需更详细的信息和实际示例,建议查阅 SCOOP 的官方文档。
下一章将探讨一种超越单机限制的不同方法。
总结
在本章中,我们介绍了通过多进程将并发应用于遗传算法的概念,这是一种缓解其计算密集型特征的自然策略。展示了两种主要方法——使用 Python 内建的 multiprocessing.Pool 类和 SCOOP 库。我们采用了一个 CPU 密集型的经典 One-Max 问题作为基准,从中获得了一些洞见。本章的最后部分讨论了使用 SCOOP 库进行分布式计算的潜力。
在下一章,我们将通过采用客户端-服务器模型,将并发的概念提升到一个新的层次。这种方法将结合使用多进程和多线程,最终利用云计算的力量进一步提升性能。
深入阅读
要了解本章中涉及的更多内容,请参考以下资源:
-
《高级 Python 编程:使用经过验证的设计模式构建高性能、并发和多线程的 Python 应用程序》,作者:Dr. Gabriele Lanaro、Quan Nguyen 和 Sakis Kasampalis,2019 年 2 月
-
SCOOP 框架文档:
scoop.readthedocs.io/en/latest/ -
Python 多进程模块文档:
docs.python.org/3/library/multiprocessing.html
第十四章:超越本地资源——在云端扩展遗传算法
本章在上一章的基础上进行扩展,上一章专注于使用多进程来提高遗传算法的性能。本章将遗传算法重构为客户端-服务器模型,其中客户端采用异步 I/O,服务器管理适应度函数计算。然后,服务器组件通过AWS Lambda部署到云端,展示了无服务器架构在优化遗传算法计算中的实际应用。
本章首先讨论将遗传算法划分为客户端和服务器组件的优势。然后,逐步实现这一客户端-服务器模型,同时使用上一章的相同 One-Max 基准问题。服务器使用 Flask 构建,客户端则利用 Python 的 asyncio 库进行异步操作。本章包括在生产级服务器上部署 Flask 应用程序的实验,最后将其部署到 AWS Lambda(一个无服务器计算服务),展示如何利用云资源提升遗传算法的计算效率。
在本章中,您将执行以下任务:
-
理解遗传算法如何重构为客户端-服务器模型
-
学习如何使用 Flask 创建一个执行适应度计算的服务器
-
在 Python 中开发一个异步 I/O 客户端,与 Flask 服务器进行交互以进行遗传算法评估
-
熟悉 Python WSGI HTTP 服务器,如 Gunicorn 和 Waitress
-
学习如何使用 Zappa 将 Flask 服务器组件部署到云端,实现 AWS Lambda 上的无服务器执行
技术要求
在本章中,我们将使用 Python 3 以及以下支持库:
-
deap
-
numpy
-
aiohttp —— 本章介绍
重要说明
如果您使用我们提供的requirements.txt文件(请参见 第三章),这些库已经包含在您的环境中。
此外,将为独立的服务器模块创建并使用一个单独的虚拟环境,并包含以下支持库:
-
flask
-
gunicorn
-
服务员
-
zappa
本章使用的程序可以在本书的 GitHub 仓库中找到,链接为 github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/tree/main/chapter_14。
请观看以下视频,查看代码演示:
遗传算法性能的下一阶段——采用客户端-服务器架构
在前一章中,我们以多处理器方式实现了遗传算法,利用遗传算法的“极易并行化”特性,显著减少了每一代的计算时间。考虑到算法中最耗时的部分通常是适应度函数的计算,我们建立了一个模拟 CPU 密集型适应度函数的基准测试。通过利用 Python 内置的多进程功能以及名为 SCOOP 的外部库,我们成功地大幅减少了运行时间。
然而,这些实现仅限于在单台机器上运行的单一程序。在处理实际问题时,这种方法很可能会遇到机器资源的限制——不仅仅是可用的 CPU 核心,还包括内存和存储等基本资源。与我们的基准程序主要消耗 CPU 时间不同,实际的适应度函数可能对处理能力和内存都有较大的需求,这带来了巨大的挑战。
我们观察到 SCOOP 库支持通过利用网络中的其他机器来进行分布式计算。然而,在本章中,我们将探索另一种方法,即将程序分为两个独立的组件。这种方法将使我们在选择这些组件的运行平台时具有更大的灵活性。这样的策略为更丰富、更强大的计算解决方案打开了可能性,包括基于云的服务或专用硬件,从而克服了仅依赖网络机器的一些固有限制。
接下来的章节将详细介绍这一新结构的设计和实现过程,我们将分几个阶段进行。
实现客户端-服务器模型
我们的计划是将遗传算法的执行分为两个独立的部分——客户端和服务器,具体如下:
-
客户端组件:客户端将集中管理进化逻辑,包括种群初始化、选择过程,以及交叉和变异等遗传操作。
-
服务器组件:服务器将负责执行资源密集型的适应度函数计算。它将利用多进程技术充分发挥计算资源,绕过 Python 的全局解释器锁(GIL)所带来的限制。
-
客户端的异步 I/O 使用:此外,客户端将采用异步 I/O,它基于单线程、事件驱动模型运行。这种方法可以高效地处理 I/O 绑定的任务,使程序在等待 I/O 进程完成时能够同时处理其他操作。在服务器通信中采用异步 I/O 后,客户端能够发送请求并继续进行其他任务,而无需被动等待响应。这就像一个服务员将客人的订单送到厨房,然后在等待的同时接收另一桌的订单,而不是呆在厨房旁等待。类似地,我们的客户端通过在等待服务器响应时不阻塞主执行线程,优化了工作流程。
这个客户端-服务器模型及其操作动态在下图中有所说明:

图 14.1:所提议的客户端-服务器设置框图
在我们深入实现该模型之前,强烈建议为服务器组件设置一个单独的 Python 环境,具体内容将在下一节中介绍。
使用单独的环境
如我们在第三章《使用 DEAP 框架》中开始编码时所建议的,我们推荐为我们的程序创建一个虚拟环境,使用venv或conda等工具。使用虚拟环境是 Python 开发中的最佳实践,因为它使我们能够将项目的依赖项与其他 Python 项目及系统的默认设置和依赖项隔离开来。
鉴于客户端部分管理遗传算法的逻辑并使用 DEAP 框架,我们可以继续在我们目前使用的相同环境中开发它。然而,建议为服务器组件创建一个单独的环境。原因有两个:首先,服务器将不使用 DEAP 依赖项,而是依赖于一组不同的 Python 库;其次,我们最终计划将服务器部署到本地计算机之外,因此最好将这个部署保持尽可能轻量级。
作为参考,您可以在此查看使用venv创建虚拟环境的过程:docs.python.org/3/library/venv.html。
类似地,使用conda进行环境管理的过程可以参考此链接:conda.io/projects/conda/en/latest/user-guide/tasks/manage-environments.html。
服务器模块的代码最好也保存在一个单独的目录中;在我们的代码库中,我们将其保存在chapter_13目录下的server目录中。
一旦我们为服务器组件创建并激活了虚拟环境,就可以开始编写组件代码。但在此之前,让我们先回顾一下我们正在解决的基准问题。
再次回顾 One-Max 问题
提醒一下,在第十三章《加速遗传算法》中,我们使用了 OneMax 问题的一个版本作为基准。这个程序的目标是找到一个指定长度的二进制字符串,使其数字之和最大。为了我们的实验,我们使用了一个简化的 10 位问题长度,同时选择了较小的种群规模和代数。另外,我们在原始的适应度评估函数中加入了一个 busy_wait() 函数。该函数在每次评估时让 CPU 忙碌三秒钟,显著增加了程序的执行时间。这个设置让我们能够实验不同的多进程方案,并比较它们的运行时长。
对于我们在客户端-服务器模型中的实验,我们将继续使用这个相同的程序,尽管会根据当前需求进行一些修改。这个方法让我们能够直接将结果与上一章得到的结果进行对比。
我们终于可以开始写一些代码了——从基于 Flask 的服务器模块开始,Flask 是一个 Python Web 应用框架。
创建服务器组件
Flask 以其轻量级和灵活性著称,将是我们在 Python 环境中服务器组件的基石。它的简洁性和用户友好的设计使其成为流行的选择,尤其适用于需要跨平台和云端安装的项目。
若要安装 Flask,确保你处于服务器的虚拟环境中,并使用以下命令:
pip install Flask
使用 Flask 的一个关键优势是它几乎不需要我们编写大量代码。我们只需要编写处理请求的处理函数,而 Flask 高效地管理所有其他底层过程。由于我们服务器组件的主要职责是处理适应度函数的计算,因此我们需要编写的代码量非常少。
我们创建的相关 Python 程序是 fitness_evaluator.py,可以通过以下链接获取:
该程序扩展了 Flask 快速入门文档中概述的最小应用,详细内容见此处:
-
首先,我们导入 Flask 类并创建这个类的一个实例。name 参数表示应用模块或包的名称:
from flask import Flask app = Flask(__name__) -
接下来,我们定义welcome()函数,用于“健康检查”目的。该函数返回一个 HTML 格式的欢迎消息。当我们将浏览器指向服务器的根 URL 时,此消息将显示,确认服务器正在运行。@app.route("/") 装饰器指定该函数应由根 URL 触发:
@app.route("/") def welcome(): return "<p>Welcome to our Fitness Evaluation Server!</p>" -
然后,我们重用上一章中的busy_wait()函数。此函数模拟一个计算密集型的适应性评估,持续时间由DELAY_SECONDS常量指定:
def busy_wait(duration): current_time = time.time() while (time.time() < current_time + duration): pass -
最后,我们定义实际的适应性评估函数oneMaxFitness()。该函数通过/one_max_fitness路由进行装饰,期望在 URL 中传递一个值(即遗传个体),然后由该函数进行处理。该函数调用busy_wait模拟处理过程,然后计算提供字符串中 1 的总和,并将该总和作为字符串返回。我们使用字符串作为该函数的输入和输出,以适应 HTTP 在 Web 应用中的基于文本的数据传输:
@app.route("/one_max_fitness/<individual_as_string>") def oneMaxFitness(individual_as_string): busy_wait(DELAY_SECONDS) individual = [int(char) for char in individual_as_string] return str(sum(individual))
要启动基于 Flask 的 Web 应用程序,首先需要进入server目录,然后激活服务器的虚拟环境。激活后,可以使用以下命令启动应用程序,该命令需要在该环境的终端中执行:
flask --app fitness_evaluator run
这将产生以下输出:
* Serving Flask app 'fitness_evaluator'
* Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on http://127.0.0.1:5000
Press CTRL+C to quit
关于 Flask 内置服务器的警告是提醒我们,它并非为性能优化设计,仅供开发用途。然而,对于我们当前的需求,这个服务器完全适合用来测试和验证应用程序的逻辑。为此,我们只需在本地计算机上使用一个网页浏览器即可。
打开浏览器并访问指定的 URL(http://127.0.0.1:5000)时,我们应看到“欢迎”消息出现,表明我们的服务器正在运行,如图所示:

图 14.2:在服务器根 URL 显示欢迎消息
接下来,让我们通过访问以下网址来测试适应性函数:127.0.0.1:5000/one_max_fitness/1100110010。
访问此 URL 会内部触发对oneMaxFitness()函数的调用,参数为1100110010。如预期的那样,在经过几秒钟的延迟后(由busy_wait()函数模拟处理时间),我们收到响应。浏览器显示数字5,表示输入字符串中 1 的总和,如图所示:

图 14.3:通过浏览器测试服务器的适应性函数
现在我们已经成功设置并验证了服务器,接下来让我们开始实现客户端。
创建客户端组件
要开始工作在客户端模块上,我们需要切换回原本用于基因算法程序的虚拟环境。这可以通过使用一个单独的终端或在 IDE 中打开一个新窗口来实现,在该环境中激活此虚拟环境。实现客户端模块的各个程序可以在以下位置找到:
我们将首先检查的程序是 01_one_max_client.py,它作为一个简单的(同步)客户端。该程序可以在以下位置找到:
这个程序改编自上一章的 01_one_max_start.py——基本的 OneMax 问题求解器。为了支持将适应度计算委托给服务器,我们做了以下修改:
-
Python 的 urllib 模块被导入。该模块提供了一套用于处理 URL 的函数和类,我们将使用这些工具向服务器发送 HTTP 请求并获取响应。
-
BASE_URL 常量被定义为指向服务器的基础 URL:
BASE_URL="http://127.0.0.1:5000" -
oneMaxFitness() 函数被重命名为 oneMaxFitness_client()。该函数将给定的个体——一个整数列表(0 或 1)——转换为一个单一的字符串。然后,它使用来自 urllib 的 urlopen() 函数,将该字符串发送到服务器上的适应度计算端点,通过将基础 URL 与函数的路由组合,并附加表示个体的字符串。该函数等待(同步)响应并将其转换回整数:
def oneMaxFitness_client(individual): individual_as_str = ''.join(str(bit) for bit in individual) response = urlopen(f'{BASE_URL}/one_max_fitness/{individual_as_str}') if response.status != 200: print("Exception!") sum_digits_str = response.read().decode('utf-8') return int(sum_digits_str),
我们现在可以在 Flask 服务器启动的同时启动这个客户端程序,并观察服务器的输出,显示请求的到来。显然,请求是一个接一个到来的,每个请求之间有明显的三秒延迟。与此同时,在客户端侧,输出与我们在上一章引入多进程之前观察到的情况相似:
gen nevals max avg
0 20 7 4.35
1 14 7 6.1
2 16 9 6.85
3 16 9 7.6
4 16 9 8.45
5 13 10 8.9
Best Individual = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
Elapsed time = 285.53 seconds
经过的时间也差不多,大约是 3 秒乘以适应度函数调用次数(95),这再次确认了我们当前客户端-服务器交互的同步性质。
现在操作已经成功,且适应度函数已被有效地分离并移至服务器,我们接下来的步骤是将客户端转变为异步客户端。
创建异步客户端
为了支持异步 I/O,我们将使用aiohttp,这是一个强大的 Python 库,用于异步 HTTP 客户端/服务器网络通信。该库及其依赖项可以通过以下命令在客户端的虚拟环境中安装:
pip install aiohttp
修改过的客户端异步版本模块不仅包括02_one_max_async_client.py程序,还包括elitism_async.py,它替代了我们迄今为止大多数程序中使用的elitism.py。02_one_max_async_client.py包含了发送适应度计算请求到服务器的函数,而elitism_async.py则管理主要的遗传算法循环,并负责调用该函数。以下小节将详细探讨这两个程序的细节。
更新 OneMax 求解器
我们从02_one_max_async_client.py开始,该程序可以在这里找到:
与之前的同步程序01_one_max_client.py相比,以下差异被突出了:
-
oneMaxFitness_client()函数已经重命名为async_oneMaxFitness_client()。除了individual,新函数还接收一个session参数,其类型为aiohttp.ClientSession;该对象负责管理异步请求并重用连接。函数签名前加上async关键字,标志着它是一个协程。这一标记使得该函数能够暂停执行,并将控制权交还给事件循环,从而实现请求的并发发送:
async def async_oneMaxFitness_client(session, individual): -
使用session对象发送 HTTP GET 请求到服务器。当收到响应时,响应内容将存储在response变量中:
async with session.get(url) as response: ... -
main()函数现在利用异步函数调用,并用async关键字定义。
-
遗传算法主循环的调用现在使用elitism_async模块,而不是原来的elitism。这个模块稍后将进行详细讨论。此外,调用前加上了await关键字,这是调用异步函数时必须使用的,以表明该函数可以将控制权交回事件循环:
population, _ = await elitism_async.eaSimpleWithElitism( population, toolbox, cxpb=P_CROSSOVER, mutpb=P_MUTATION, ngen=MAX_GENERATIONS, stats=stats, halloffame=hof, verbose=True) -
对main()函数的调用是通过asyncio.run()进行的。这种调用方法用于指定异步程序的主入口点。它启动并管理asyncio事件循环,从而允许异步任务的调度和执行:
asyncio.run(main())
更新遗传算法循环
相应的程序elitism_async.py可以在这里找到:
如前所述,该程序是熟悉的elitism.py的修改版,旨在异步执行遗传算法的主循环并管理对适应度函数的异步调用。以下是关键的修改部分:
-
首先,在循环开始之前会创建一个aiohttp.TCPConnector对象。这个对象负责创建和管理用于发送 HTTP 请求的 TCP 连接。limit参数在这里用来控制与服务器的并发连接数:
connector = aiohttp.TCPConnector(limit=100) -
接下来,创建一个aiohttp.ClientSession对象。这个会话对象用于异步发送 HTTP 请求,并在代码的其余部分中使用。它也会被传递到async_oneMaxFitness_client()函数中,在那里它用来向服务器发送请求。会话在整个循环中保持活跃,从而确保响应与相应的请求能够匹配:
async with aiohttp.ClientSession(connector=connector) as session: -
原先通过map()函数调用适应度评估函数,使用该函数对所有需要更新适应度值的个体(invalid_ind)进行evaluate操作,现在已被以下两行代码替代。这些代码行创建了一个名为evaluation_tasks的Task对象列表,表示调度的异步适应度函数调用,然后等待所有任务完成:
evaluation_tasks = [asyncio.ensure_future( toolbox.evaluate(session, ind)) for ind in invalid_ind] fitnesses = await asyncio.gather(*evaluation_tasks)
我们现在已经准备好使用新的异步客户端,接下来将详细介绍。
运行异步客户端
首先,确保 Flask 服务器正在运行。如果它还没有启动,可以使用以下命令来启动:
flask --app fitness_evaluator run
接下来,让我们启动02_one_max_async_client.py程序,并观察来自服务器和客户端窗口的输出。
与之前的实验相比,现在可以明显看到请求一次性到达服务器,并且是并发处理的。在客户端,尽管输出看起来与上次运行相似,但运行时间大大提高——速度提升超过 10 倍:
gen nevals max avg
0 20 7 4.35
1 14 7 6.1
2 16 9 6.85
3 16 9 7.6
4 16 9 8.45
5 13 10 8.9
Best Individual = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
Elapsed time = 25.61 seconds
现在我们已经学会了如何使用客户端-服务器模型解决 OneMax 问题,接下来我们将学习如何使用生产环境应用服务器来托管模型。
使用生产级应用服务器
如我们之前所提到的,Flask 自带的内置服务器并没有针对性能进行优化,主要用于开发目的。虽然它在我们的异步实验中表现尚可,但当将应用程序迁移到生产环境时,强烈建议使用生产级别的mod_wsgi。这些服务器专为满足生产环境的需求而设计,提供增强的性能、安全性、稳定性和可扩展性。正如我们接下来将展示的,迁移到这些服务器之一是一个相对简单的任务。
使用 Gunicorn 服务器
我们将探索的第一个服务器选项是Gunicorn,即绿色独角兽的缩写。它是一个广泛使用的 Python WSGI HTTP 服务器,专为 Unix 系统设计,以其简单和高效而著称,是部署 Python Web 应用程序的热门选择。
尽管 Gunicorn 在 Windows 上没有原生支持,但它可以通过Windows Subsystem for Linux(WSL)来使用,WSL 由 Windows 10 及更高版本支持。WSL 允许你在 Windows 上直接运行 GNU/Linux 环境,而无需传统虚拟机或双启动设置的开销。Gunicorn 可以在这个 Linux 环境中安装和运行。
要安装 Gunicorn,确保你处于服务器的虚拟环境中,并使用以下命令:
pip install gunicorn
然后,使用以下命令启动服务器:
gunicorn -b 127.0.0.1:5000 --workers 20 fitness_evaluator:app
-b参数是可选的,用于在本地 URL 127.0.0.1:5000 上运行服务器,与原 Flask 服务器配置保持一致。默认情况下,Gunicorn 运行在端口8000上。
--workers参数指定了工作进程的数量。如果没有这个参数,Gunicorn 默认使用一个工作进程。
一旦 Gunicorn 服务器启动,运行客户端将产生以下输出:
gen nevals max avg
0 20 7 4.35
1 14 7 6.1
2 16 9 6.85
3 16 9 7.6
4 16 9 8.45
5 13 10 8.9
Best Individual = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
Elapsed time = 18.71 seconds
回想一下,在这个实验中我们可以达到的最佳理论结果是 18 秒,因为我们有 6 个“轮次”的适应度计算,而每个轮次的最佳可能结果是 3 秒,即单次适应度评估的时间。我们在这里获得的结果令人印象深刻,接近这一理论极限。
如果你希望使用原生 Windows 服务器,我们将在下一小节介绍 Waitress 服务器。
使用 Waitress 服务器
Waitress 是一个生产级别的纯 Python WSGI 服务器。它是一个跨平台的服务器,兼容多种操作系统,包括 Unix、Windows 和 macOS。
Waitress 常作为 Gunicorn 的替代品使用,特别是在 Gunicorn 不可用或不被偏好的环境中,如 Windows,或者当需要纯 Python 解决方案时。
要安装 Waitress,确保你处于服务器的虚拟环境中,并使用以下命令:
pip install waitress
接下来,我们需要对 Flask 应用进行一些修改。修改后的程序fitness_evaluator_waitress.py可以在这里找到:
与原始程序fitness_evaluator.py的不同之处在此处突出显示:
-
首先,我们从 Waitress 模块中导入serve函数:
from waitress import serve -
然后,我们使用serve()函数从程序内部启动服务器。该函数允许我们通过参数指定服务器的配置。在我们的例子中,我们设置了主机、端口和处理请求的线程数:
if __name__ == "__main__": serve(app, host='0.0.0.0', port=5000, threads=20)
通过运行以下程序可以启动服务器:fitness_evaluator_waitress.py。
打破局限
下一步的逻辑选择是将应用程序的服务器组件部署到一个单独的平台。这样做带来几个关键优势,包括可扩展性、增强的性能和更高的可靠性。虽然可以选择使用自己的硬件在本地部署服务器,但利用云计算服务通常能提供更高效和有效的解决方案。我们将在下一节中详细讨论这一点。
通过云计算触及天空
云计算服务为企业和个人提供了按需访问各种应用程序、存储解决方案和计算能力的机会。这些服务消除了对物理基础设施的大量前期投资需求,使用户只需为所使用的资源付费。云计算支持广泛的应用,包括数据存储、网站托管、先进分析和人工智能等,彻底改变了组织管理和部署 IT 解决方案的方式。
云平台的额外优势包括高级安全措施、数据冗余和全球覆盖,确保全球用户低延迟。此外,云服务减少了对硬件的前期资本投资需求,并最小化了持续维护和升级的负担。这种方法使得我们能更多地专注于应用程序开发,而非基础设施管理。
在考虑将基于 Flask 的服务器组件部署到云平台时,重要的是要注意大多数主要云服务提供商都提供了简便的方法来部署 Flask 应用程序。例如,可以轻松将 Flask 应用程序部署到Azure App Service,这是微软 Azure 云计算服务提供的一个完全托管的平台,用于托管 Web 应用程序。该平台简化了许多部署和管理过程,使其成为 Flask 部署的便捷选择。有关如何将 Flask 应用程序部署到 Azure App Service 的详细说明和指南,可以在此链接中找到:
learn.microsoft.com/zh-cn/azure/app-service/quickstart-python
亚马逊网络服务(AWS)提供了其他多个选项。您可以使用Amazon EC2来全面控制虚拟服务器,或者选择AWS Fargate,如果您更喜欢一种不需要管理底层服务器的基于容器的计算服务。一个更简单的选择是使用AWS Elastic Beanstalk,这是一个用户友好的服务,用于部署和扩展 Web 应用程序。Elastic Beanstalk 自动化了诸如容量配置、负载均衡、自动扩展和应用程序健康监控等各种部署细节。使用 AWS 命令行界面(CLI)将现有的 Flask 应用程序部署到 Elastic Beanstalk 是直接的,具体步骤如下:
docs.aws.amazon.com/elasticbeanstalk/latest/dg/create-deploy-python-flask.html
然而,在本章的其余部分,我们的重点转向第四个选项——AWS Lambda。AWS Lambda 代表了应用程序部署和管理的范式转变。作为一项无服务器计算服务,它允许在无需配置或管理服务器的情况下执行代码,并根据传入的请求自动扩展。这个无服务器的方法为部署 Flask 应用程序提供了一套独特的优势。
重要——Lambda 的限制
在继续之前,必须记住,尽管 AWS Lambda 服务功能强大且灵活,但它确实存在一些限制和约束。其中最重要的是每次函数调用的最大执行时间限制,目前为 15 分钟。这意味着,对于一个基因算法,如果单次适应度函数评估的时间预计超过该时限,则我们接下来描述的方法将不适用,应该考虑使用上述替代方法之一,如 AWS Elastic Beanstalk。
Lambda 的其他限制包括内存和计算资源的限制、部署包大小的限制以及并发执行数量的限制,具体描述如下:
docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html.
尽管存在上述限制,AWS Lambda 仍然是许多基因算法问题的可行选项。在许多情况下,完成一次适应度函数评估所需的时间远远在 Lambda 规定的 15 分钟执行时间限制之内。此外,Lambda 服务提供的资源通常足够支持这些应用程序。这种兼容性使得 AWS Lambda 成为高效执行基因算法的一个具有吸引力的选择,我们将在接下来的章节中探讨这一点。
AWS Lambda 与 API Gateway——完美组合
AWS Lambda 是 AWS 提供的一项无服务器计算服务,允许执行代码而无需服务器的配置或管理。作为功能即服务(FaaS)的典型例子,Lambda 使开发者能够编写和更新响应特定事件的代码。在这种模型中,底层的物理硬件、服务器操作系统维护、自动扩展和容量配置都由平台管理,允许开发人员专注于应用代码中的各个功能。Lambda 的自动扩展根据每次触发调整计算能力,确保高可用性。
使用 AWS Lambda 的成本效益体现在其计费结构上,按实际使用的计算时间收费,当代码未运行时不会产生任何费用。此外,Lambda 与其他 AWS 服务的无缝集成使其成为开发复杂应用程序的宝贵工具。一个关键的集成是与AWS API Gateway,这是一个完全托管的服务,作为应用程序的“前门”,使 API Gateway 能够在 HTTP 请求响应中触发 Lambda 函数。这个集成促进了无服务器架构的创建,其中 Lambda 函数通过 API Gateway 的 API 调用触发。
这个强大的组合使我们能够将现有的 Flask 应用程序部署到 AWS 云中,利用 API Gateway 和 Lambda 服务。更重要的是,得益于 Zappa 框架(将在下一节中介绍),我们可以在不做任何修改的情况下部署 Flask 应用,充分利用无服务器架构的优势。
无服务器 Python 与 Zappa
Zappa是一个开源框架,简化了在 AWS Lambda 上部署 Python Web 应用程序的过程。它特别适用于 Flask(以及 Django——另一个 Python Web 框架)应用程序。Zappa 处理所有运行 Web 应用程序所需的设置和配置,将其转变为无服务器应用程序。这包括打包应用程序、设置必要的 AWS 配置并将其部署到 Lambda 上。
此外,Zappa 还提供数据库迁移、功能执行调度和与各种 AWS 服务的集成,使其成为一个综合性的工具,用于在 AWS Lambda 上部署 Python Web 应用程序。
要安装 Zappa,确保你在服务器的虚拟环境中,然后使用以下命令:
pip install zappa
在继续之前,确保你有一个有效的 AWS 账户,具体步骤将在下一个小节中说明。
设置 AWS 账户
为了能够将我们的服务器部署到 AWS 云,你需要一个有效的 AWS 账户。AWS 免费套餐,新 AWS 用户可以使用,允许你在一定的使用限制内免费探索和使用 AWS 服务。
如果你目前还没有 AWS 账户,可以在这里注册一个免费账户:
接下来,通过以下链接的说明安装 AWS CLI:
docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html
你还需要设置你的 AWS 凭证文件,具体方法请参阅以下内容:
wellarchitectedlabs.com/common/documentation/aws_credentials/
这些将在后台由 Zappa 使用,随着我们继续部署服务。
部署服务器模块到 Lambda 服务
现在是时候使用 Zappa 将我们的 Flask 应用程序部署到 AWS 了。进入服务器目录,确保服务器的虚拟环境已激活,然后执行以下命令:
zappa init
这将启动一个交互式对话框。Zappa 会提示你提供各种详细信息,例如生产环境名称(默认值为 dev)、用于存储文件的唯一 S3 桶名称(它会为你建议一个唯一名称),以及你的应用程序名称(在你的案例中应该设置为 fitness_evaluator.app)。它还会询问全局部署选项,默认选择是 n。在此设置过程中,你通常可以接受 Zappa 提供的所有默认值。
该初始化过程的结果是一个名为 zappa_settings.json 的文件。此文件包含应用程序的部署配置。如果需要,你可以手动编辑此文件以修改配置或添加其他选项。
现在我们已经准备好部署应用程序。如果在 Zappa 配置过程中选择了 dev 作为生产环境的名称,请使用以下命令:
zappa deploy dev
部署过程可能需要几分钟。完成后,你将看到一个显示 部署完成! 的消息,并附有一个 URL。此 URL 作为你新部署应用程序的基本 URL。
我们现在可以通过将浏览器指向新 URL 来手动测试部署。响应 /one_max_fitness/1100110010 会返回基本 URL。几秒钟后,响应 5 应该会显示出来。
在我们继续使用异步客户端模块与新部署的服务器进行交互之前,我们可以登录到 AWS 控制台查看已部署的内容。此步骤是可选的——如果你已经熟悉 AWS 控制台,可以跳过并直接进入下一节。
审查在 AWS 上的部署
要查看 Zappa 部署的主要组件,请首先登录到 AWS 控制台:aws.amazon.com/console/
登录后,导航到 Lambda 服务,你可以查看可用的 Lambda 函数列表。你应该可以看到你的新部署函数,类似于以下屏幕截图:

图 14.4: Zappa 部署创建的 Lambda 函数
在我们的例子中,Zappa 创建的 Lambda 函数名为 server-dev。这个名字来源于应用程序所在目录的名称(server)和我们选择的生产环境名称(dev)的组合。
点击函数名称将带我们进入函数概览屏幕,在这里我们可以进一步探索详细信息,如函数的运行时环境、触发器、配置设置和监控指标,如下所示:

图 14.5:Lambda 函数概览屏幕
接下来,我们进入 API 网关服务,您可以查看可用 API 的列表。您应该能看到我们新部署的 API,名称与 Lambda 函数相同,如下所示:

图 14.6:Zappa 部署创建的 API
点击 API 名称将带我们进入资源屏幕;然后,选择ANY链接将展示一个图表,说明 API 网关如何将传入的请求路由到 Lambda 函数,并将响应返回给客户端,如下图所示:

图 14.7:API 网关资源屏幕
当您点击右侧的 Lambda 图标时,它将显示我们的 Lambda 函数的名称。此名称包括一个超链接,点击后会带我们回到 Lambda 函数的页面。
运行基于 Lambda 的客户端服务器
为了更新我们的异步客户端程序 02_one_max_async_client.py 以适应我们新部署的基于 Lambda 的服务器,我们只需要做一个更改:将现有的 BASE_URL 变量值替换为 Zappa 部署提供的新 URL。
完成这些操作后,运行客户端会得到与之前相似的输出,表明即使服务器基础设施发生变化,遗传算法的运行方式没有改变:
gen nevals max avg
0 20 7 4.35
1 14 7 6.1
2 16 9 6.85
3 16 9 7.6
4 16 9 8.45
5 13 10 8.9
Best Individual = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
Elapsed time = 19.54 seconds
多次重新运行客户端,结果显示经过的时间值在 19 到 21 秒之间,考虑到服务器在云环境中运行,并且存在固有的网络延迟和无服务器功能初始化时间,这个时间是合理的。
退部署服务器
一旦我们使用完通过 Zappa 部署的服务器,最好通过在服务器的虚拟环境中执行 zappa undeploy 命令来退部署其基础设施:
zappa undeploy dev
这个操作通过移除不再使用的 AWS 部署资源,帮助高效地管理成本和资源。
总结
在本章中,你学习了如何将遗传算法重构为客户端-服务器模型。客户端使用异步 I/O,而服务器则使用 Flask 构建,负责处理适应度函数的计算。然后,服务器组件通过 Zappa 成功地部署到云端,并作为 AWS Lambda 服务运行。这种方法展示了无服务器计算在提升遗传算法性能方面的有效应用。
在下一章中,我们将探讨遗传算法如何在艺术领域中得到创造性应用。具体来说,我们将学习如何利用这些算法,通过半透明的重叠形状重建著名画作的图像。这种方法不仅为艺术与技术提供了独特的结合,而且还为遗传算法在传统计算以外的领域提供了深刻的应用洞察。
进一步阅读
欲了解本章涵盖的更多内容,请参考以下资源:
-
使用 Flask 构建 Web 应用程序 由 Italo Maia 编写,2015 年 6 月
-
专家级 Python 编程:通过学习最佳编码实践和高级编程概念掌握 Python,第 4 版 由 Michal Jaworski 和 Tarek Ziade 编写,2021 年 5 月(异步 编程 章节)
-
AWS Lambda 快速入门指南:学习如何在 AWS 上构建和部署无服务器应用程序 由 Markus Klems 编写,2018 年 6 月
-
掌握 AWS Lambda:学习如何构建和部署无服务器应用程序 由 Yohan Wadia 和 Udita Gupta 编写,2017 年 8 月
-
Zappa 框架文档:
-
Python asyncio 库:
第五部分:相关技术
本部分探讨了遗传算法在图像处理中的应用,并介绍了其他生物启发的求解技术。第一章专门讲解了如何使用遗传算法进行图像重建,方法是通过半透明多边形重建图像,最终通过一个基于遗传算法的程序,使用这些技术重建一幅著名的画作。在接下来的章节中,讨论的范围扩展到包括遗传编程、增强拓扑神经进化(NEAT)和粒子群优化,每个方法都通过基于 Python 的求解程序进行演示。最后,我们概述了该领域的其他几种计算范式,进一步扩展了我们对进化计算方法的理解。
本部分包含以下章节:
-
第十五章**,遗传图像重建
-
第十六章**,其他进化与生物启发的计算技术
第十五章:使用遗传算法进行图像进化重建
本章将尝试遗传算法在图像处理中的一种应用方式——用一组半透明的多边形重建图像。在这个过程中,我们将获得有用的图像处理经验,并且对进化过程有直观的了解。
我们将首先概述 Python 中的图像处理,并熟悉两个有用的库——Pillow 和 OpenCV-Python。接下来,我们将了解如何从零开始使用多边形绘制图像,并计算两张图像之间的差异。然后,我们将开发一个基于遗传算法的程序,使用多边形重建一幅著名画作的一部分,并检查结果。
本章将涵盖以下主题:
-
熟悉几个用于 Python 的图像处理库
-
理解如何通过编程使用多边形绘制图像
-
了解如何通过编程比较两张给定的图像
-
使用遗传算法,结合图像处理库,通过多边形重建图像
本章将通过概述图像重建任务开始。
技术要求
本章将使用 Python 3 和以下支持库:
-
deap
-
numpy
-
matplotlib
-
seaborn
-
pillow – 本章介绍
-
opencv-python (cv2) – 本章介绍
重要提示
如果你使用我们提供的requirements.txt文件(参见第三章),这些库已经包含在你的环境中了。
本章使用的程序可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/tree/main/chapter_15。
查看以下视频,查看代码的实际运行:
用多边形重建图像
遗传算法在图像处理中的一个最引人注目的应用是通过一组半透明、重叠的形状重建给定图像。这种方法不仅在图像处理经验方面令人愉快且富有教育意义,还提供了进化过程的直观视觉呈现。此外,这些实验可能会加深对视觉艺术的理解,并有助于图像分析和压缩的进展。
在这些图像重建实验中——其中有许多变种可以在互联网上找到——通常使用一张熟悉的图像,往往是著名的画作或其一部分,作为参考。目标是通过拼接一组重叠的形状,通常是多边形,且这些形状具有不同的颜色和透明度,来构建一张相似的图像。
在这里,我们将通过使用遗传算法方法和 deap 库来应对这一挑战,就像我们在本书中为多种类型的问题所做的那样。然而,由于我们需要绘制图像并将其与参考图像进行比较,因此让我们先了解一下 Python 中的图像处理基础知识。
Python 中的图像处理
为了实现我们的目标,我们需要进行各种图像处理操作;例如,我们需要从头开始创建一张图像,在图像上绘制形状,绘制图像,打开图像文件,将图像保存到文件,比较两幅图像,并可能调整图像大小。在接下来的部分中,我们将探讨在使用 Python 时,如何执行这些操作。
Python 图像处理库
在众多可供 Python 程序员使用的图像处理库中,我们选择了两个最为突出的库。以下小节将简要讨论这些库。
Pillow 库
Pillow 是原始 Python Imaging Library(PIL)的一个当前维护版本。它支持打开、处理和保存多种格式的图像文件。由于它允许我们处理图像文件、绘制形状、控制透明度并操作像素,我们将使用它作为重建图像的主要工具。
该库的主页可以在这里找到:python-pillow.org/。Pillow 的典型安装使用 pip 命令,如下所示:
pip install Pillow
Pillow 库使用 PIL 命名空间。如果你已经安装了原始的 PIL 库,你需要先卸载它。更多信息可以在文档中找到,文档地址是 pillow.readthedocs.io/en/stable/index.html。
OpenCV-Python 库
OpenCV 是一个复杂的库,提供与计算机视觉和机器学习相关的众多算法。它支持多种编程语言,并且在不同的平台上可用。OpenCV-Python 是该库的 Python 接口。它结合了 C++接口的速度和 Python 语言的易用性。在这里,我们将主要利用这个库来计算两幅图像之间的差异,因为它允许我们将图像表示为数值数组。我们还将使用其 GaussianBlur 函数,该函数会产生图像的模糊版本。
OpenCV-Python 的主页可以在这里找到:github.com/opencv/opencv-python
该库包含四个不同的包,它们都使用相同的命名空间(cv2)。在单个环境中,应该只选择安装其中一个包。为了我们的目的,可以使用以下命令,只安装主要模块:
pip install opencv-python
更多信息可以在 OpenCV 文档中找到,文档地址为 docs.opencv.org/master/。
使用多边形绘制图像
要从头绘制一张图像,我们可以使用 Pillow 的 Image 和 ImageDraw 类,代码如下:
image = Image.new('RGB', (width, height))
draw = 'RGB' and 'RGBA' are the values for the mode argument. The 'RGB' value indicates three 8-bit values per pixel – one for each of the colors of red (R), green (G), and blue (B). The 'RGBA' value adds a fourth 8-bit value (A) representing the *alpha* (opacity) level of the drawings to be added. The combination of an RGB base image and an RGBA drawing will allow us to draw polygons of varying degrees of transparency on top of a black background.
Now, we can add a polygon to the base image by using the `ImageDraw` class’s `polygon` function, as shown in the following example. The following statement will draw a triangle on the image:
draw.polygon([(x1, y1), (x2, y2), (x3, y3)], (red, green, blue,
alpha))
The following list explains the `polygon` function arguments in more detail:
* The **(x1, y1)**, **(x2, y2)**, and **(x3, y3)** tuples represent the triangle’s three vertices. Each tuple contains the *x, y* coordinates of the corresponding vertex within the image.
* **red**, **green**, and **blue** are integer values in the range of [0, 255], each representing the intensity of the corresponding color of the polygon.
* **Alp~ha** is an integer value in the range of [0, 255], representing the opacity value of the polygon (a lower value means more transparency).
Note
To draw a polygon with more vertices, we would need to add more (x i, y i) tuples to the list.
Using the `polygon` function repetitively, we can add more and more polygons, all drawn onto the same image and possibly overlapping each other, as shown in the following figure:

Figure 15.1: A plot of overlapping polygons with varying colors and opacity values
Once we draw an image using polygons, we need to compare it to the reference image, as described in the next subsection.
Measuring the difference between images
Since we would like to construct an image that is as similar as possible to the original one, we need a way to evaluate the similarity or the difference between the two given images. The most common method to evaluate the similarity between images is the pixel-based **mean squared error** (**MSE**), which involves conducting a pixel-by-pixel comparison. This requires, of course, that both images are of the same dimensions. The MSE metric can be calculated as follows:
1. Calculate the square of the difference between each pair of matching pixels from both images. Since each pixel in the drawing is represented using three separate values – red, green, and blue – the difference for each pixel is calculated across these three dimensions.
2. Compute the sum of all these squares.
3. Divide the sum by the total number of pixels.
When both images are represented using the OpenCV (cv2) library, which essentially represents an image as a numeric array, this calculation can be performed in a straightforward manner as follows:
MSE = np.sum(
(cv2Image1.astype("float") -
cv2Image2.astype("float"))**2)/float(numPixels)
When the two images are identical, the MSE value will be zero. Consequently, minimizing this metric can be used as the objective of our algorithm, which will be further discussed in the next section.
Using genetic algorithms to reconstruct images
As we discussed previously, our goal in this experiment is to use a familiar image as a reference and create a second image, as similar as possible to the reference, using a collection of overlapping polygons of varying colors and transparencies. Using the genetic algorithms approach, each candidate solution is a set of such polygons, and evaluating the solution is carried out by creating an image using these polygons and comparing it to the reference image.
As usual, the first decision we need to make is how these solutions are represented. We will discuss this in the next subsection.
Solution representation and evaluation
As we mentioned previously, our solution consists of a set of polygons within the image boundaries. Each polygon has its own color and transparency. Drawing such a polygon using the Pillow library requires the following arguments:
* A list of tuple, [(x 1, y 1), (x 2, y 2), … , (x n, y n)], representing the vertices of the polygon. Each tuple contains the *x, y* coordinates of the corresponding vertex within the image. Therefore, the values of the *x* coordinates are in the range [0, image width – 1], while the values of the *y* coordinates are in the range [0, image height – 1].
* Three integer values in the range of [0, 255], representing the *red*, *green*, and *blue* components of the polygon’s color.
* An additional integer value in the range of [0, 255], representing the *alpha* – or opacity – value of the polygon.
This means that for each polygon in our collection, we will need [2 × (polygo n − size) + 4] parameters. A *triangle*, for example, will require 10 parameters (2x3+4), while a *hexagon* will require 16 parameters (2x6+4). Consequently, a collection of triangles will be represented using a list in the following format, where every 10 parameters represent a single triangle:
[x 11, y 11, x 12, y 12, x 13, y 13, r 1, g 1, b 1, alph a 1, x 21, y 21, x 22, y 22, x 23, y 23, r 2, g 2, b 2, alph a 2, …]
To simplify this representation, we will use float numbers in the range of [0, 1] for each of the parameters. Before drawing the polygons, we will expand each parameter accordingly so that it fits within its required range – image width and height for the coordinates of the vertices and [0, 255] for the colors and opacity values.
Using this representation, a collection of 50 triangles will be represented as a list of 500 float values between 0 and 1, like so:
[0.1499488467959301, 0.3812631075049196, 0.000439458056299,
0.9988170920722447, 0.9975357316889601, 0.9997461395379549,
...
0.9998952203500615, 0.48148512088979356, 0.083285509827404]
Evaluating a given solution means dividing this long list into “chunks” representing individual polygons – in the case of triangles, each chunk will have a length of 10\. Then, we need to create a new, blank image and draw the various polygons from the list on top of it, one by one.
Finally, the difference between the resulting image and the original (reference) image needs to be calculated. As discussed in the previous section, this will be done using the pixel-based MSE.
This (somewhat elaborate) score evaluation procedure is implemented by a Python class, which will be described in the next subsection.
Python problem representation
To encapsulate the image reconstruction challenge, we’ve created a Python class called `ImageTest`. This class is contained in the `image_test.py` file, which is located at [`github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_15/image_test.py`](https://github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_15/image_test.py).
The class is initialized with two parameters: the path of the file containing the reference image and the number of vertices of the polygons that are being used to construct the image. The class provides the following public methods:
* **polygonDataToImage()**: Accepts the list containing the polygon data we discussed in the previous subsection, divides this list into chunks representing individual polygons, and creates an image containing these polygons by drawing the polygons one by one onto a blank image.
* **getDifference()**: Accepts polygon data, creates an image containing these polygons, and calculates the difference between this image and the reference image using the *MSE* method.
* **blur()**: Accepts an image in PIL format, converts it to OpenCV (cv2) format, and then applies Gaussian blurring. The intensity of the blur is determined by the **BLUR_KERNEL_SIZE** constant.
* **plotImages()**: For visual comparison purposes, creates a side-by-side plot of three images:
* The reference image (to the left)
* The given, polygon-reconstructed image (to the right)
* A blurred version of the reconstructed image (in the middle)
* **saveImage()**: Accepts polygon data, creates an image containing these polygons, creates a side-by-side plot of this image next to the reference image, and saves the plot in a file.
During the run of the genetic algorithm, the `saveImage()` method will be called every 100 generations in order to save a side-by-side image comparison representing a snapshot of the reconstruction process. Calling this method will be carried out by a callback function, as described in the next subsection.
Genetic algorithm implementation
To reconstruct a given image with a set of semi-transparent overlapping polygons using a genetic algorithm, we’ve created a Python program called `01_reconstruct_with_polygons.py`, which is located at [`github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_15/01_reconstruct_with_polygons.py`](https://github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_15/01_reconstruct_with_polygons.py).
Since we are using a list of floats to represent a solution – the polygons’ vertices, colors, and transparency values – this program is very similar to the function optimization programs we saw in *Chapter 6**, Optimizing Continuous Functions*, such as the one we used for the *Eggholder* *function*’s optimization.
The following steps describe the main parts of this program:
1. We start by setting the problem-related constant values. **POLYGON_SIZE** determines the number of vertices for each polygon, while **NUM_OF_POLYGONS** determines the total number of polygons that will be used to create the reconstructed image:
```
POLYGON_SIZE = 3
NUM_OF_POLYGONS = 100
```py
2. After setting the genetic algorithm constants, we continue by creating an instance of the **ImageTest** class, which will allow us to create images from polygons and compare these images to the reference image, as well as save snapshots of our progress:
```
imageTest = image_test.ImageTest(MONA_LISA_PATH, POLYGON_SIZE)
```py
3. Next, we set the upper and lower boundaries for the float values we will be searching for. As we mentioned previously, we will use float values for all our parameters and set them all to the same range, between 0.0 and 1.0, for convenience. When evaluating a solution, the values will be expanded to their actual range, and converted into integers when needed:
```
BOUNDS_LOW, BOUNDS_HIGH = 0.0, 1.0
```py
4. Since our goal is to minimize the difference between the images – the reference image and the one we are creating using polygons – we define a single objective, *minimizing* fitness strategy:
```
creator.create("FitnessMin", base.Fitness, weights=(-1.0,))
```py
5. Now, we need to create a helper function that will create random real numbers that are uniformly distributed within a given range. This function assumes that the range is the same for every dimension, as is the case in our solution:
```
def randomFloat(low, up):
return [random.uniform(l, u) for l, u in zip([low] * \
NUM_OF_PARAMS, [up] * NUM_OF_PARAMS)]
```py
6. Next, we use the preceding function to create an operator that randomly returns a list of floats, all in the desired range of [0, 1]:
```
toolbox.register("attrFloat", randomFloat, BOUNDS_LOW,
BOUNDS_HIGH)
```py
7. This is followed by defining an operator that fills up an individual instance using the preceding operator:
```
toolbox.register("individualCreator",
tools.initIterate,
creator.Individual,
toolbox.attrFloat)
```py
8. Then, we instruct the genetic algorithm to use the **getDiff()** method for fitness evaluation. This, in turn, calls the **getDifference()** method of the **ImageTest** instance. As a reminder, this method, which we described in the previous subsection, accepts an individual representing a list of polygons, creates an image containing these polygons, and calculates the difference between this image and the reference image using the *MSE* method:
```
def getDiff(individual):
return imageTest.getDifference(individual, METRIC),
toolbox.register("evaluate", getDiff)
```py
9. It’s time to choose our genetic operators. For the selection operator, we will use *tournament selection* with a tournament size of 2\. As we saw in *Chapter 4**, Combinatorial Optimization*, this selection scheme works well in conjunction with the *elitist approach* that we plan to utilize here as well:
```
toolbox.register("select", tools.selTournament, tournsize=2)
```py
10. As for the *crossover* operator (aliased with **mate**) and the *mutation* operator (**mutate**), since our solution representation is a list of floats bounded to a range, we will use the specialized continuous bounded operators provided by the DEAP framework – **cxSimulatedBinaryBounded** and **mutPolynomialBounded**, respectively – which we first saw in *Chapter 6**, Optimizing* *Continuous Functions*:
```
toolbox.register("mate",
tools.cxSimulatedBinaryBounded,
low=BOUNDS_LOW,
up=BOUNDS_HIGH,
eta=CROWDING_FACTOR)
toolbox.register("mutate",
tools.mutPolynomialBounded,
low=BOUNDS_LOW,
up=BOUNDS_HIGH,
eta=CROWDING_FACTOR,
indpb=1.0/NUM_OF_PARAMS)
```py
11. As we have done multiple times before, we will use the *elitist approach*, where the **hall of fame** (**HOF**) members – the current best individuals – are always passed untouched to the next generation. However, this time, we’re going to add a new feature to this implementation – a *callback function* that will be used to save the image every 100 generations (we will discuss this callback in more detail in the next subsection):
```
population, logbook = \
elitism_callback.eaSimpleWithElitismAndCallback(
population,
toolbox,
cxpb=P_CROSSOVER,
mutpb=P_MUTATION,
ngen=MAX_GENERATIONS,
callback=saveImage,
stats=stats,
halloffame=hof,
verbose=True)
```py
12. At the end of the run, we print the best solution and use the **plotImages()** function to show a side-by-side visual comparison to the reference image:
```
best = hof.items[0]
print("Best Solution = ", best)
print("Best Score = ", best.fitness.values[0])
imageTest.plotImages(imageTest.polygonDataToImage(best))
```py
13. In addition, we have employed the multiprocessing method of using a process pool, as demonstrated and tested in *Chapter 13**, Accelerating Genetic Algorithms: The Power of Concurrency*. This approach is a straightforward way to accelerate the execution of our algorithm. It simply involves adding the following lines to encapsulate the call to **eaSimpleWithElitismAndCallback()**:
```
with multiprocessing.Pool(processes=20) as pool:
toolbox.register("map", pool.map)
```py
Before we look at the results, let’s discuss the implementation of the callback function.
Adding a callback to the genetic run
To be able to save the best current image every 100 generations, we need to introduce a modification to the main genetic loop. As you may recall, toward the end of *Chapter 4**, Combinatorial Optimization*, we already made one modification to `deap`’s simple genetic algorithm main loop that allowed us to introduce the *elitist approach*. To be able to introduce that change, we created the `eaSimpleWithElitism()` method, which is contained in a file called `elitism.py`. This method was a modified version of the DEAP framework’s `eaSimple()` method, which is contained in the `algorithms.py` file. We modified the original method by adding the elitism functionality, which takes the members of the HOF – the current best individuals – and passes them untouched to the next generation at every iteration of the loop. Now, for the purpose of implementing a callback, we will introduce another small modification and change the name of the method to `eaSimpleWithElitismAndCallback()`. We will also rename the file containing it to `elitism_and_callback.py`.
There are two parts to this modification, as follows:
1. The first part of the modification consists of adding an argument called **callback** to the main-loop method:
```
def eaSimpleWithElitismAndCallback(population,
toolbox, cxpb, mutpb, ngen, callback=None,
stats=None, halloffame=None, verbose=__debug__):
```py
This new argument represents an external function that will be called after each iteration.
2. The other part is within the method. Here, the callback function is called after the new generation has been created and evaluated. The current generation number and the current best individual are passed to the callback as arguments:
```
if callback:
callback(gen, halloffame.items[0])
```py
Being able to define a callback function that will be called after each generation may prove useful in various situations. To take advantage of it here, we’ll define the `saveImage()` function back in our `01_reconstruct_with_polygons.py` program. We will use it to save a side-by-side image of the current best image and the reference image, every 100 generations, as follows:
1. We use the *modulus* (**%**) operator to activate the method only once every 100 generations:
```
if gen % 100 == 0:
```py
2. If this is one of these generations, we create a folder for the images if one does not exist. The folder’s name references the polygon size and the number of polygons – for example, **run-3-100** or **run-6-50**, under the **images/results/** path:
```
RESULTS_PATH = os.path.join(BASE_PATH, "results",
f"run-{POLYGON_SIZE}-{NUM_OF_POLYGONS}")
...
if not os.path.exists(RESULTS_PATH):
os.makedirs(RESULTS_PATH)
```py
3. Next, we save the image of the best current individual in that folder. The name of the image contains the number of generations that have been passed – for example, **after-300-generations.png**:
```
imageTest.imageTest.saveImage(polygonData,
os.path.join(RESULTS_PATH, f"after-{gen}-gen.png"),
f"After {gen} Generations")
```py
We are finally ready to run this algorithm with reference images and check out the results.
Image reconstruction results
To test our program, we will use a section of the famous Mona Lisa portrait by *Leonardo da Vinci*, considered the most well-known painting in the world, as seen here:

Figure 15.2: Head crop of the Mona Lisa painting
Source: [`commons.wikimedia.org/wiki/File:Mona_Lisa_headcrop.jpg`](https://commons.wikimedia.org/wiki/File:Mona_Lisa_headcrop.jpg)
Artist: Leonardo da Vinci. Licensed under Creative Commons CC0 1.0: [`creativecommons.org/publicdomain/zero/1.0/`](https://creativecommons.org/publicdomain/zero/1.0/)
Before proceeding with the program, it’s important to note that the extensive polygon data and complex image processing operations involved make the running time for our genetic image reconstruction experiments significantly longer than other programs tested earlier in this book. These experiments could take several hours each to complete.
We will begin our image reconstruction using 100 triangles as the polygons:
POLYGON_SIZE = 3
NUM_OF_POLYGONS = 100
The algorithm will run for 5,000 generations with a population size of 200\. As discussed earlier, a side-by-side image comparison is saved every 100 generations. At the end of the run, we can review these saved images to observe the evolution of the reconstructed image.
The following figure showcases various milestones from the resulting side-by-side saved images. As mentioned before, the middle image in each row presents a blurred version of the reconstructed image. This blurring aims to soften the sharp corners and straight lines that are typical of polygon-based reconstructions, creating an effect akin to squinting when viewing the image:

Figure 15.3: Milestone results of Mona Lisa reconstruction using 100 triangles
The end result bears a close resemblance to the original image and can be readily recognized as the Mona Lisa.
Reducing the triangle count
It is reasonable to assume that the results would be even better when increasing the number of triangles. But what if we wanted to *minimize* this number? If we reduce the number of triangles to 20, we might still be able to tell that this is the Mona Lisa, as the following results show:

Figure 15.4: Results of Mona Lisa reconstruction using 20 triangles and MSE
However, when the triangle count is further reduced to 15, the results are no longer recognizable, as seen here:

Figure 15.5: Results of Mona Lisa reconstruction using 15 triangles and MSE
A possible way to improve these results is described in the next subsection.
Blurring the fitness
Since the reconstruction becomes significantly cruder when the triangle count is low, perhaps we can improve this result by basing the fitness on the similarity between the original image and the *blurred version* of the reconstructed image, which is less crude. To try this out, we’ve created a slightly modified version of the original Python program, called `02_reconstruct_with_polygons_blur.py`, which is located at [`github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_15/02_reconstruct_with_polygons_blur.py`](https://github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_15/02_reconstruct_with_polygons_blur.py).
The modifications are highlighted as follows:
1. The image comparison results of this program are saved into a separate directory called **blur**.
2. The fitness function calculation now includes an optional argument, **blur=True**, when calling the **getDifference()** function. Consequently, this function will call **getMseBlur()** instead of the original **getMse()**. The **getMseBlur()** function blurs the given image before calculating the MSE:
```
def getMseBlur(self, image):
return np.sum(
(self.blur(image).astype("float") -
self.refImageCv2.astype("float")) ** 2
) / float(self.numPixels)
```py
The results of running this program for 20 triangles are shown in the following figure:

Figure 15.6: Results of Mona Lisa reconstruction using 20 triangles and MSE with blur
Meanwhile, the results for 15 triangles are shown here:

Figure 15.7: Results of Mona Lisa reconstruction using 15 triangles and MSE with blur
The resulting images appear more recognizable, which makes this method a potentially viable way to achieve a lower polygon count.
Other experiments
There are many variations that you can explore. One straightforward variation is increasing the number of vertices in the polygons. We anticipate more accurate results from this approach, as the shapes become more versatile. However, it’s important to note that the size of the individual polygons grows, which typically necessitates a larger population and/or more generations to achieve reasonable results.
Another interesting variation is to apply the “blur” fitness, previously used to minimize the number of polygons, to a large polygon count. This approach might lead to a somewhat “erratic” reconstruction, which is then smoothed by the blur function. The following result illustrates this, using 100 hexagons with 400 individuals and 5,000 generations, employing the “blur” MSE-based fitness:

Figure 15.8: Results of Mona Lisa reconstruction using 100 hexagons and MSE with blur
There are many other possibilities and combinations to experiment with, such as the following:
* Increasing the number of polygons
* Changing the population size and the number of generations
* Using non-polygonal shapes (such as circles or ellipses) or regular shapes (such as squares or equilateral triangles)
* Using different types of reference images (including paintings, drawings, photos, and logos)
* Opting for grayscale images instead of colored ones
Have fun creating and experimenting with your own variations!
Summary
In this chapter, you were introduced to the popular concept of reconstructing existing images with overlapping, semi-transparent polygons. You explored various image processing libraries in Python, learning how to programmatically create images from scratch using polygons and calculate the difference between two images. Subsequently, we developed a genetic algorithm-based program to reconstruct a segment of a famous painting using polygons and explored several variations in the process. We also discussed numerous possibilities for further experimentation.
In the next chapter, we will describe and demonstrate several problem-solving techniques related to genetic algorithms, as well as other biologically inspired computational algorithms.
Further reading
For more information about the topics that were covered in this chapter, please refer to the following resources:
* *Hands-On Image Processing with Python*, Sandipan Dey, November 30, 2018
* *Grow Your Own* *Picture*: [`chriscummins.cc/s/genetics`](https://chriscummins.cc/s/genetics)
* *Genetic Programming: Evolution of Mona* *Lisa*: [`rogerjohansson.blog/2008/12/07/genetic-programming-evolution-of-mona-lisa/`](https://rogerjohansson.blog/2008/12/07/genetic-programming-evolution-of-mona-lisa/)
第十六章:其他进化和生物启发式计算技术
在本章中,你将拓宽视野,发现与遗传算法相关的几种新的问题解决和优化技术。通过实现问题解决的 Python 程序,我们将展示这一扩展家族的三种不同技术——遗传编程、神经进化拓扑增强(NEAT)和粒子群优化。最后,我们还将简要概述几种其他相关的计算范式。
本章将涉及以下主题:
-
进化计算家族算法
-
理解遗传编程的概念及其与遗传算法的区别
-
使用遗传编程解决偶校验 检查问题
-
理解NEAT的概念及其与遗传算法的区别
-
使用 NEAT 解决偶校验检查问题
-
理解粒子群优化的概念
-
使用粒子群优化算法优化Himmelblau 函数
-
理解其他几种进化和生物学启发式技术的原理
我们将从本章开始,揭示进化计算的扩展家族,并讨论其成员共享的主要特点。
技术要求
在本章中,我们将使用 Python 3,并搭配以下支持库:
-
deap
-
numpy
-
networkx
-
neatpy——在本章中介绍
-
pygame
重要说明
如果你使用的是我们提供的requirements.txt文件(见第三章),这些库会已经存在于你的环境中。
本章中使用的程序可以在本书的 GitHub 库中找到:github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/tree/main/chapter_16。
查看以下视频,看看代码的实际应用:
进化计算与生物启发式计算
本书中,我们已经介绍了名为遗传算法的求解技术,并将其应用于多种类型的问题,包括组合优化、约束满足、连续函数优化,以及机器学习和人工智能。然而,正如我们在第一章《遗传算法简介》中提到的那样,遗传算法只是一个更大算法家族——进化计算中的一支。这个家族包括各种相关的问题解决和优化技术,所有这些技术都从查尔斯·达尔文的自然进化理论中汲取灵感。
这些技术共享的主要特征如下:
-
起点是一个初始集(种群)的候选解。
-
候选解(个体)会通过迭代更新,生成新的代。
-
创建新一代涉及淘汰不太成功的个体(选择),并对一些个体引入小的随机变化(突变)。也可以应用其他操作,如与其他个体的交互(交叉)。
-
结果是,随着代数的增加,种群的适应度提高;换句话说,候选解在解决当前问题上变得更加有效。
更广泛地说,由于进化计算技术基于各种生物系统或行为,它们通常与被称为生物启发计算的算法家族有所重叠。
在接下来的章节中,我们将介绍一些进化计算和生物启发计算中最常用的成员——有些会详细介绍,而其他则仅简要提及。我们将从详细介绍一个有趣的技术开始,它使我们能够进化实际的计算机程序:遗传编程。
遗传编程
遗传编程是遗传算法的一种特殊形式——也就是我们在本书中应用的技术。在这种特殊情况下,我们正在进化的候选解——或者说个体——是计算机程序,因此得名。换句话说,当我们应用遗传编程时,我们在进化计算机程序,以找到能够出色完成特定任务的程序。
如你所记得,遗传算法使用候选解的表示,通常称为染色体。这个表示受基因操作符的作用,即选择、交叉和突变。将这些操作符应用于当前代时,结果是产生一个新的解代,预计比前代产生更好的结果。在我们到目前为止所研究的大多数问题中,这种表示通常是某种类型值的列表(或数组),如整数、布尔值或浮点数。然而,为了表示程序,我们通常使用树结构,如以下图示所示:

图 16.1:简单程序的树结构表示
来源:commons.wikimedia.org/wiki/File:Genetic_Program_Tree.png
图片来自 Baxelrod。
前面的树形结构表示了树下方展示的计算过程。这个计算等同于一个接受两个参数 X 和 Y 的短程序(或函数),并根据它们的值返回一个特定的输出。为了创建和进化这样的树结构,我们需要定义两个不同的集合:
-
终端,或者是树的叶节点。这些是可以在树中使用的参数和常量值。在我们的示例中,X 和 Y 是参数,而 2.2、11 和 7 是常量。在树创建时,常量也可以在某个范围内随机生成。
-
基本操作符,或者是树的内部节点。这些是接受一个或多个参数并生成单一输出值的函数(或运算符)。在我们的示例中,+、-、****、÷ 是接受两个参数的基本操作符,而 cos 是接受一个参数的基本操作符。
在第二章,《理解遗传算法的关键组件》中,我们展示了遗传操作符单点交叉如何作用于二进制值列表。交叉操作通过切割每个父节点的一部分并交换父节点之间的切割部分,创建了两个后代。类似地,树形表示的交叉操作符可以从每个父节点中分离出一个子树(一个分支或一组分支),并交换这些被分离的分支来创建后代树,示例如下图所示:

图 16.2:表示程序的两个树结构之间的交叉操作
来源:commons.wikimedia.org/wiki/File:GP_crossover.png
图片由 U-ichi 提供
在这个例子中,顶行的两个父节点有交换了子树,形成了第二行的两个后代。交换的子树用矩形框标出。
类似地,变异操作符旨在对单个个体引入随机变化,它的实现方式是从候选解中选择一个子树,并将其替换为一个随机生成的子树。
我们在本书中一直使用的 deap 库原生支持遗传编程。在下一节中,我们将使用该库实现一个简单的遗传编程示例。
遗传编程示例——偶校验检查
对于我们的示例,我们将使用遗传编程创建一个实现偶校验检查的程序。在此任务中,输入的可能值为 0 或 1。若输入中值为 1 的数量为奇数,则输出值应为 1,从而得到一个偶数个 1 的总数;否则,输出值应为 0。下表列出了三输入情况下,输入值的各种可能组合及其对应的偶校验输出值:
| in_0 | in_1 | in_2 | 偶校验 |
|---|---|---|---|
| 0 | 0 | 0 | 0 |
| 0 | 0 | 1 | 1 |
| 0 | 1 | 0 | 1 |
| 0 | 1 | 1 | 0 |
| 1 | 0 | 0 | 1 |
| 1 | 0 | 1 | 0 |
| 1 | 1 | 0 | 0 |
| 1 | 1 | 1 | 1 |
表 16.1:三输入偶校验的真值表
这种表格通常被称为真值表。从这个真值表可以看出,偶校验经常作为基准使用的原因之一是,输入值的任何单一变化都会导致输出值发生变化。
偶校验还可以通过逻辑门来表示,例如AND、OR、NOT和异或(XOR)。NOT门接受一个输入并将其反转,而其他三种门类型每种都接受两个输入。为了使输出为 1,AND门要求两个输入都为 1,OR门要求至少一个输入为 1,而XOR门要求恰好一个输入为 1,如下表所示:
| in_0 | in_1 | AND | OR | XOR |
|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 |
| 0 | 1 | 0 | 1 | 1 |
| 1 | 0 | 0 | 1 | 1 |
| 1 | 1 | 1 | 1 | 0 |
表 16.2:两输入的 AND、OR 和 XOR 操作的真值表
实现三输入偶校验有多种可能的方法。最简单的方法是使用两个XOR门,如下图所示:

图 16.3:使用两个 XOR 门实现的三输入偶校验
在接下来的小节中,我们将使用遗传编程创建一个小程序,该程序通过使用AND、OR、NOT和XOR逻辑运算实现偶校验。
遗传编程实现
为了进化出实现偶校验逻辑的程序,我们创建了一个基于遗传编程的 Python 程序,名为01_gp_even_parity.py,可以在github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_16/01_gp_even_parity.py找到。
由于遗传编程是遗传算法的一种特例,如果你已经浏览过本书前几章中展示的程序,那么这个程序的很多部分应该会对你来说非常熟悉。
以下步骤描述了该程序的主要部分:
-
我们首先设置与问题相关的常量值。这里,NUM_INPUTS决定了偶校验检查器的输入数量。为了简化,我们使用3作为值;但也可以设置更大的值。NUM_COMBINATIONS常量表示输入值的可能组合数,它类似于我们之前看到的真值表中的行数:
NUM_INPUTS = 3 NUM_COMBINATIONS = 2 ** NUM_INPUTS -
接下来是我们之前多次见过的熟悉的遗传算法常量:
POPULATION_SIZE = 60 P_CROSSOVER = 0.9 P_MUTATION = 0.5 MAX_GENERATIONS = 20 HALL_OF_FAME_SIZE = 10 -
然而,遗传编程需要一些额外的常量,这些常量指的是候选解的树表示形式。它们在以下代码中定义。我们将在检查本程序的其余部分时看到它们是如何使用的:
MIN_TREE_HEIGHT = 3 MAX_TREE_HEIGHT = 5 MUT_MIN_TREE_HEIGHT = 0 MUT_MAX_TREE_HEIGHT = 2 LIMIT_TREE_HEIGHT = 17 -
接下来,我们计算真值表的偶校验,以便在需要检查给定候选解的准确性时可以作为参考。parityIn矩阵表示真值表的输入列,而parityOut向量表示输出列。Python 的itertools.product()函数是嵌套for循环的优雅替代,它可以遍历所有输入值的组合:
parityIn = list(itertools.product([0, 1], repeat=NUM_INPUTS)) parityOut = [sum(row) % 2 for row in parityIn] -
现在,是时候创建原始元素集合了——也就是将用于我们进化程序的运算符。第一个声明使用以下三个参数创建一个集合:
-
使用集合中的原始元素生成的程序名称(在这里,我们称之为main)
-
程序的输入数量
-
用于命名输入的前缀(可选)
这三个参数用于创建以下原始元素集合:
primitiveSet = gp.PrimitiveSet("main", NUM_INPUTS, "in_") -
-
现在,我们用将作为程序构建块的各种函数(或运算符)填充原始集合。对于每个运算符,我们将引用实现它的函数以及它期望的参数个数。尽管我们可以为此定义自己的函数,但在本例中,我们利用了现有的 Python operator模块,它包含了许多有用的函数,包括我们所需的逻辑运算符:
primitiveSet.addPrimitive(operator.and_, 2) primitiveSet.addPrimitive(operator.or_, 2) primitiveSet.addPrimitive(operator.xor, 2) primitiveSet.addPrimitive(operator.not_, 1) -
以下定义设置了要使用的终止值。如前所述,这些是可以作为树的输入值的常量。在我们的例子中,使用0和1作为值是合适的:
primitiveSet.addTerminal(1) primitiveSet.addTerminal(0) -
由于我们的目标是创建一个实现偶校验真值表的程序,我们将尽量减少程序输出与已知输出值之间的差异。为此,我们将定义一个单一的目标——即最小化适应度策略:
creator.create("FitnessMin", base.Fitness, weights=(-1.0,)) -
现在,我们将创建Individual类,基于deap库提供的PrimitiveTree类:
creator.create("Individual", gp.PrimitiveTree,\ fitness=creator.FitnessMin) -
为了帮助我们构建种群中的个体,我们将创建一个辅助函数,使用之前定义的原始集合生成随机树。在这里,我们利用了deap提供的genFull()函数,并为其提供了原始集合,以及定义生成树的最小和最大高度的值:
toolbox.register("expr", gp.genFull, pset=primitiveSet, min_=MIN_TREE_HEIGHT, max_=MAX_TREE_HEIGHT) -
接下来定义了两个操作符,第一个通过前面的辅助操作符创建个体实例。另一个生成这样个体的列表:
toolbox.register("individualCreator", tools.initIterate, creator.Individual, toolbox.expr) toolbox.register("populationCreator", tools.initRepeat, list, toolbox.individualCreator) -
接下来,我们创建一个操作符,用于编译给定的原始树为 Python 代码,使用compile()函数,这个函数是由deap提供的。因此,我们将在我们创建的一个函数中使用这个编译操作符,叫做parityError()。对于给定的个体——表示一个表达式的树——这个函数会统计在真值表中,计算结果与预期结果不同的行数:
toolbox.register("compile", gp.compile, pset=primitiveSet) def parityError(individual): func = toolbox.compile(expr=individual) return sum(func(*pIn) != pOut for pIn, pOut in zip(parityIn, parityOut)) -
然后,我们必须指示遗传编程算法使用getCost()函数进行适应度评估。该函数返回我们刚才看到的奇偶错误,作为元组形式,这是底层进化算法所需的:
def getCost(individual): return parityError(individual), toolbox.register("evaluate", getCost) -
现在是选择我们的遗传操作符的时候了,从选择操作符(别名为select)开始。对于遗传编程,这个操作符通常是我们在本书中一直使用的锦标赛选择。在这里,我们使用的锦标赛大小为2:
toolbox.register("select", tools.selTournament, tournsize=2) -
至于交叉操作符(别名为mate),我们将使用 DEAP 提供的专用遗传编程cxOnePoint()操作符。由于进化程序由树表示,这个操作符接受两个父树,并交换它们的部分内容,从而创建两个有效的后代树:
toolbox.register("mate", gp.cxOnePoint) -
接下来是变异操作符,它引入对现有树的随机变化。变异定义为两个阶段。首先,我们指定一个辅助操作符,利用由deap提供的专用遗传编程genGrow()函数。这个操作符在由两个常量定义的限制内创建一个子树。然后,我们定义变异操作符本身(别名为mutate)。该操作符利用 DEAP 的mutUniform()函数,随机替换给定树中的一个子树,替换成通过辅助操作符生成的随机子树:
toolbox.register("expr_mut", gp.genGrow, min_=MUT_MIN_TREE_HEIGHT, max_=MUT_MAX_TREE_HEIGHT) toolbox.register("mutate", gp.mutUniform, expr=toolbox.expr_mut, pset=primitiveSet) -
为了防止种群中的个体树木过度增长,可能包含过多的原始元素,我们需要引入膨胀控制措施。我们可以通过使用 DEAP 的staticLimit()函数来实现,它对交叉和变异操作的结果施加树的高度限制:
toolbox.decorate("mate", gp.staticLimit( key=operator.attrgetter("height"), max_value=LIMIT_TREE_HEIGHT)) toolbox.decorate("mutate", gp.staticLimit( key=operator.attrgetter("height"), max_value=LIMIT_TREE_HEIGHT)) -
程序的主循环与我们在前几章中看到的非常相似。在创建初始种群、定义统计度量和创建 HOF 对象之后,我们调用进化算法。像之前做过的多次一样,我们必须采用精英方法,即将 HOF 成员——当前最优个体——始终传递给下一代,保持不变:
population, logbook = elitism.eaSimpleWithElitism( population, toolbox, cxpb=P_CROSSOVER, mutpb=P_MUTATION, ngen=MAX_GENERATIONS, stats=stats, halloffame=hof, verbose=True) -
在运行结束时,我们打印出最佳解决方案,以及用于表示它的树的高度和长度——即树中包含的运算符总数:
best = hof.items[0] print("-- Best Individual = ", best) print(f"-- length={len(best)}, height={best.height}") print("-- Best Fitness = ", best.fitness.values[0]) -
最后我们需要做的事情是绘制表示最佳解决方案的树的图形插图。为此,我们必须使用图形和网络库NetworkX(nx),它在第五章《约束满足》中有所介绍。我们首先调用deap提供的graph()函数,它将单个树分解为构建图所需的节点、边和标签,然后使用适当的networkx函数创建图:
nodes, edges, labels = gp.graph(best) g = nx.Graph() g.add_nodes_from(nodes) g.add_edges_from(edges) pos = nx.spring_layout(g) -
接下来,我们绘制节点、边和标签。由于该图的布局不是经典的层次树结构,我们必须通过将顶部节点着色为红色并放大它来区分它:
nx.draw_networkx_nodes(g, pos, node_color='cyan') nx.draw_networkx_nodes(g, pos, nodelist=[0], node_color='red', node_size=400) nx.draw_networkx_edges(g, pos) nx.draw_networkx_labels(g, pos, **{"labels": labels, "font_size": 8})运行此程序时,我们得到以下输出:
gen nevals min avg 0 60 2 3.91667 1 50 1 3.75 2 47 1 3.45 ... 5 47 0 3.15 ... 20 48 0 1.68333 -- Best Individual = xor(and_(not_(and_(in_1, in_2)), not_(and_(1, in_2))), xor(or_(xor(in_1, in_0), and_(0, 0)), 1)) -- length=19, height=4 -- Best Fitness = 0.0
由于这是一个简单的问题,适应度很快达到了最小值 0,这意味着我们成功找到了一个正确地重现偶校验检查真值表的解决方案。然而,结果表达式由 19 个元素和四个层次组成,看起来过于复杂。下面的图表显示了程序生成的结果:

图 16.4:表示初始程序找到的奇偶校验解决方案的图表
如前所述,图中的红色节点表示程序树的顶部,它映射到表达式中的第一个XOR操作。
这个相对复杂的图表的原因是使用更简单的表达式没有优势。只要它们符合树高度的限制,被评估的表达式不会因复杂性而受到惩罚。在下一个子章节中,我们将通过对程序进行小的修改来尝试改变这种情况,期望以更简洁的解决方案实现相同的结果——偶校验检查的实现。
简化解决方案
在我们刚刚看到的实现中,已经采取了措施来限制表示候选解的树的大小。然而,我们找到的最佳解似乎过于复杂。强制算法生成更简单结果的一种方法是对复杂性施加小的成本惩罚。这种惩罚应该足够小,以避免偏好于未能解决问题的更简单解决方案。相反,它应该作为两个良好解决方案之间的决胜局,从而优先选择更简单的解决方案。这种方法已经在位于github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_16/02_gp_even_parity_reduced.py的02_gp_even_parity_reduced.py Python 程序中实施。
这个程序几乎与之前的程序相同,除了几个小的变化:
-
主要的变化是引入到成本函数中,该算法试图最小化。在原始计算的错误之上,增加了依赖于树高度的小惩罚措施:
def getCost(individual): return parityError(individual) + individual.height / 100, -
唯一的其他变化发生在运行结束后,在打印找到的最佳解决方案之后。在这里,除了打印健身价值外,我们还打印了实际的奇偶错误,而没有出现在健身中的惩罚:
print("-- Best Parity Error = ", parityError(best))
运行这个修改版本,我们得到以下输出:
gen nevals min avg
0 60 2.03 3.9565
1 50 2.03 3.7885
...
5 47 0.04 3.45233
...
10 48 0.03 3.0145
...
15 49 0.02 2.57983
...
20 45 0.02 2.88533
-- Best Individual = xor(xor(in_0, in_1), in_2)
-- length=5, height=2
-- Best Fitness = 0.02
-- Best Parity Error = 0
从前面的输出中,我们可以看出,经过五代的迭代后,该算法能够找到一个正确重现偶校验检查真值表的解决方案,因为此时的健身价值几乎为 0。然而,随着算法的继续运行,树的高度从四(0.04 的惩罚)降低到了二(0.02 的惩罚)。因此,最佳解决方案非常简单,仅包含五个元素 - 三个输入和两个XOR运算符。我们找到的解决方案代表了我们之前看到的最简单已知解决方案,其中包含两个XOR门。这由程序生成的以下绘图所示:

图 16.5:代表由修改后程序找到的奇偶校验解的绘图
尽管遗传编程被认为是遗传算法的一个子集,但下一节描述了一种更专门的进化计算形式 - 专门用于创建神经网络架构。
NEAT
在第九章,深度学习网络的架构优化,我们展示了如何使用简单的遗传算法来找到适合特定任务的前馈神经网络(也称为多层感知器或MLP)的最佳架构。为此,我们限制了三个隐藏层,并使用了一个具有每个层占位符的固定大小染色体来编码每个网络,其中 0 或负值表示该层不存在。
将这个想法进一步发展,NEAT是一种专门用于更灵活和渐进创建神经网络的进化技术,由Kenneth Stanley和Risto Miikkulainen于 2002 年创建。
NEAT 从小而简单的神经网络开始,并允许它们通过在几代中添加和修改神经元和连接来进化。与使用固定大小的染色体不同,NEAT 将解决方案表示为直接映射到人工神经网络的有向图,其中节点表示神经元,节点之间的连接表示突触。这使得 NEAT 不仅可以进化连接的权重,还可以进化网络的结构本身,包括添加和删除神经元和连接。
NEAT 的交叉运算符专门设计用于神经网络。它对齐并结合来自父网络的匹配神经元和连接,同时保持独特的‘创新’标识符。为了实现这种匹配,通过使用全局创新编号跟踪基因的历史,随着新基因的添加,此编号会增加。
此外,NEAT 采用一种物种化机制,根据它们的结构相似性将个体(神经网络)分组为物种。这种分组鼓励物种内的竞争,而不是物种之间的竞争。该机制有助于确保创新在其各自的生态位中有机会繁荣,然后再受到激烈竞争的影响。
NEAT(以及其他相关的神经进化技术)已被应用于许多领域,包括财务预测、药物发现、进化艺术、电子电路设计和机器人技术;然而,它最常见的应用是在强化学习应用中,如游戏玩法。
NEAT 示例 - 偶数奇偶校验
我们将通过解决与前一节中使用的相同的三输入偶数奇偶校验问题,使用 NEAT 技术进行说明,来创建同一奇偶校验函数的前馈神经网络实现。
关于神经网络,偶校验检查,也称为XOR 问题,已知单个感知器无法实现它,因为它形成的模式无法用一条直线或简单的线性函数分开。为了捕捉这种非线性,所需的最小网络包括输入层和输出层,外加一个包含两个神经元的隐藏层。在下一小节中,我们将设置并查看 NEAT 是否能找到这个最小解决方案。
NEAT 实现
为了利用 NEAT 技术演化一个实现偶校验逻辑的神经网络,我们创建了一个名为03_neat_even_parity.py的 Python 程序,存放在github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_16/03_neat_even_parity.py。
Python NEAT 库
有几个强大的 Python 库实现了 NEAT 技术,其中最著名的是NEAT-Python库。然而,在我们的示例中,我们将使用轻量级的neatpy库,因为它简洁且易于使用。如果该库尚未安装,可以使用以下命令进行安装:
pip install neatpy
此外,PyGame库是可视化解决方案进展所必需的。如果尚未安装,可以使用以下命令进行安装:
pip install pygame
程序
以下步骤描述了该程序的主要部分:
-
与遗传编程示例类似,我们将首先设置与问题相关的常量值。NUM_INPUTS决定了偶校验检查器的输入数量。
-
由于我们希望在程序结束时保存一个包含最佳解决方案的网络结构的图像,因此需要确保已创建一个用于存放图像的文件夹:
IMAGE_PATH = os.path.join( os.path.dirname(os.path.realpath(__file__)), "images") if not os.path.exists(IMAGE_PATH): os.makedirs(IMAGE_PATH) -
现在,我们必须使用PyGame库的功能设置图形显示,以实时“动画”方式展示算法的进度:
pg.init() screen = pg.display.set_mode((400, 400)) screen.fill(colors['lightblue']) -
接下来,我们必须为 NEAT 算法设置几个选项:
-
我们网络的输入数量(这将与NUM_INPUTS相同)。
-
输出的数量(在我们的例子中为 1)。
-
种群大小(在我们的例子中为 150)。
-
适应度阈值。如果最佳解决方案超过此值,算法就认为问题已解决并停止。由于最佳适应度等于真值表中的行数(表示我们对所有行得出了正确的结果),因此我们必须将阈值设置为一个略低于该值的数字:
Options.set_options(NUM_INPUTS, 1, 150, 2**NUM_INPUTS - 0.1)
-
-
接下来,我们必须在实现所需偶校验的输入和输出时计算parityIn和parityOut,这与我们在遗传编程示例中所做的类似:
parityIn = list(itertools.product([0, 1], repeat=NUM_INPUTS)) parityOut = [sum(row) % 2 for row in parityIn] -
现在,是时候定义parityScore()函数了,该函数用于评估给定的神经网络(由nn参数表示)。由于得分需要为正数,我们从最大得分开始,然后减去每个期望网络输出与网络实际(浮动)输出值之间差的平方:
score = 2**NUM_INPUTS for pIn, pOut in zip(parityIn, parityOut): output = nn.predict(pIn)[0] score-= (output - pOut) ** 2此外,评分还包括每个网络节点的小额惩罚项,使得较小的架构占据优势:
score -= len(nn.nodes) * 0.01 -
接下来是另一个工具函数,draw_current()。它通过调用neatpy库的draw_brain_pygame()绘制当前最佳解的架构(节点和连接);此外,它还通过使用draw_species_bar_pygame()函数绘制当前物种的状态,展示了物种分化机制。
-
创建初始种群后,我们进入了 NEAT 算法的主循环。得益于neatpy库的简洁性,这个循环非常简明。它从对当前种群进行评分开始,这也是进化算法中的常见步骤:
for nn in p.pool: nn.fitness = parityScore(nn) -
主循环通过调用库中的epoch()函数继续进行,这个函数执行一次 NEAT 进化步骤,产生新的种群。然后,它会打印当前种群,并通过调用draw_current()绘制当前最佳个体及物种状态。
-
一旦循环退出,结果会被打印出来,真值表会被检查,最新的图形也会保存为图片文件。
在运行程序时,包含网络可视化和物种演化的图形会出现,并在每一代更新自身,从而创建出一个“动画”视图来显示状态。下图包含了在运行过程中捕捉的四个“快照”:

图 16.6:三输入偶数奇偶校验问题的 NEAT 解的演化阶段
这些快照展示了网络如何从仅有输入层和输出层节点及一个物种开始,然后发展出多个物种,接着增加了一个隐藏层节点,再增加第二个隐藏层节点。
在运行结束时,程序会将最后一个快照保存为图片,保存在images文件夹中。如下所示:

图 16.7:三输入偶数奇偶校验问题的 NEAT 解的最终演化阶段
在图示中,白色圆圈代表网络的节点,左上角的圆圈表示隐藏层和输出层节点的偏置值。蓝色边代表正权重(或正偏置值)连接,而橙色边代表负权重(或负偏置值)连接。与传统的 MLP 不同,NEAT 算法创建的网络可以有“跳跃”某一层的连接,例如橙色边直接将底部输入节点连接到输出节点,以及层内连接。
程序的打印输出显示,找到的最佳网络能够解决问题:
best fitness = 7.9009068332812635
Number of nodes = 7
Checking the truth table:
input (0, 0, 0), expected output 0, got 0.050 -> 0
input (0, 0, 1), expected output 1, got 0.963 -> 1
input (0, 1, 0), expected output 1, got 0.933 -> 1
input (0, 1, 1), expected output 0, got 0.077 -> 0
input (1, 0, 0), expected output 1, got 0.902 -> 1
input (1, 0, 1), expected output 0, got 0.042 -> 0
input (1, 1, 0), expected output 0, got 0.029 -> 0
input (1, 1, 1), expected output 1, got 0.949 -> 1
如我们所见,找到的最佳架构包括一个包含两个节点的单一隐藏层。
在下一节中,我们将研究另一个受生物启发的基于群体的算法。然而,这个算法偏离了使用传统的选择、交叉和变异基因操作符,而是采用了一套不同的规则,在每一代中修改种群——欢迎来到群体行为的世界!
粒子群优化
粒子群优化(PSO)的灵感来源于自然界中个体有机体的群体,例如鸟群或鱼群,通常称为群体。这些有机体在没有中央监督的情况下在群体中相互作用,共同朝着一个共同的目标努力。这种观察到的行为催生了一种计算方法,可以通过使用一组候选解来解决或优化给定问题,这些候选解由类似于群体中有机体的粒子表示。粒子在搜索空间中移动,寻找最佳解,它们的移动遵循简单的规则,这些规则涉及它们的位置和速度(方向速度)。
PSO 算法是迭代的,每次迭代中,都会评估每个粒子的位置,并在必要时更新其迄今为止的最佳位置,以及整个粒子群体中的最佳位置。然后,按照以下信息更新每个粒子的速度:
-
粒子当前的速度和运动方向——代表惯性
-
粒子迄今为止找到的最佳位置(局部最佳)——代表认知力
-
整个群体迄今为止找到的最佳位置(全局最佳)——代表社会力
接下来,根据新计算的速度更新粒子的位置。
这个迭代过程持续进行,直到满足某个停止条件,例如迭代次数限制。此时,算法将当前群体的最佳位置作为解。
这个简单但高效的过程将在下一节中详细说明,我们将讨论一个使用 PSO 算法优化函数的程序。
PSO 示例——函数优化
为了演示,我们将使用粒子群优化算法来寻找Himmelblau 函数的最小位置,这个函数是一个常用的基准,之前我们在第六章中使用遗传算法进行了优化,标题为优化连续函数。这个函数可以表示如下:

图 16.8:Himmelblau 函数
来源:commons.wikimedia.org/wiki/File:Himmelblau_function.svg
图片由 Morn the Gorn 提供。
提醒一下,这个函数可以通过数学公式表达如下:
f(x, y) = (x 2 + y − 11) 2 + (x + y 2 − 7) 2
它有四个全局最小值,评估结果为 0,图中用蓝色区域表示。这些最小值位于以下坐标:
-
x=3.0, y=2.0
-
x=−2.805118, y=3.131312
-
x=−3.779310, y=−3.283186
-
x=3.584458, y=−1.848126
在我们的示例中,我们将尝试找到这些最小值中的任何一个。
粒子群优化实现
为了使用粒子群优化算法定位Himmelblau 函数的最小值,我们创建了一个名为04_pso_himmelblau.py的 Python 程序,地址:github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_16/04_pso_himmelblau.py。
以下步骤描述了程序的主要部分:
-
我们首先设置程序中将使用的各种常量。首先,我们有当前问题的维度—在我们这里是2—它决定了每个粒子的位置和速度的维度。接下来是种群大小——即群体中粒子的总数——以及运行算法的代数或迭代次数:
DIMENSIONS = 2 POPULATION_SIZE = 20 MAX_GENERATIONS = 500 -
然后是一些额外的常量,它们影响粒子的创建和更新。我们将在分析程序的其余部分时看到它们的作用:
MIN_START_POSITION, MAX_START_POSITION = -5, 5 MIN_SPEED, MAX_SPEED = -3, 3 MAX_LOCAL_UPDATE_FACTOR = MAX_GLOBAL_UPDATE_FACTOR = 2.0 -
由于我们的目标是定位Himmelblau 函数的最小值,我们需要定义一个单一目标——即,最小化适应度策略:
creator.create("Particle class creator looks as follows:creator.create("Particle",
np.ndarray,
fitness=creator.FitnessMin,
speed=None,
best=None)
-
为了帮助我们构建种群中的单个粒子,我们需要定义一个辅助函数,用于创建并初始化一个随机粒子。我们将使用numpy库的random.uniform()函数,随机生成新粒子的位置信息和速度数组,在给定的边界范围内:
def createParticle(): particle = creator.Particle( np.random.uniform( MIN_START_POSITION, MAX_START_POSITION, DIMENSIONS)) particle.speed = np.random.uniform( MIN_SPEED, MAX_SPEED, DIMENSIONS) return particle -
该函数用于定义创建粒子实例的操作符。这反过来被种群创建操作符使用:
toolbox.register("particleCreator", createParticle) toolbox.register("populationCreator", tools.initRepeat, list, toolbox.particleCreator) -
接下来是作为算法核心的方法,在我们这里,
ndarray类型是二维的,计算是逐元素进行的,每次处理一个维度。 -
更新后的粒子速度实际上是粒子原始速度(代表惯性)、粒子已知的最佳位置(认知力)和整个群体的最佳已知位置(社交力)的结合:
def updateParticle(particle, best): localUpdateFactor = np.random.uniform( 0, MAX_LOCAL_UPDATE_FACTOR, particle.size) globalUpdateFactor = np.random.uniform( 0, MAX_GLOBAL_UPDATE_FACTOR, particle.size) localSpeedUpdate = localUpdateFactor * (particle.best - particle) globalSpeedUpdate = globalUpdateFactor * (best - particle) particle.speed = particle.speed + (localSpeedUpdate + lobalSpeedUpdate) -
updateParticle()方法继续执行,确保新的速度不超过预设的限制,并使用更新后的速度更新粒子的位置。如前所述,位置和速度都是ndarray类型,并且每个维度都有单独的组件:
particle.speed = np.clip(particle.speed, MIN_SPEED, MAX_SPEED) particle[:] = particle + particle.speed -
然后,我们必须将updateParticle()方法注册为工具箱操作符,该操作符将在后续的主循环中使用:
toolbox.register("update", updateParticle) -
我们仍然需要定义要优化的函数——在我们的例子中是Himmelblau 函数——并将其注册为适应度评估操作符:
def himmelblau(particle): x = particle[0] y = particle[1] f = (x ** 2 + y - 11) ** 2 + (x + y ** 2 - 7) ** 2 return f, # return a tuple toolbox.register("evaluate", himmelblau) -
现在我们终于到达了main()方法,可以通过创建粒子群体来开始它:
population = toolbox.populationCreator( n=POPULATION_SIZE) -
在开始算法的主循环之前,我们需要创建stats对象,用于计算群体的统计数据,以及logbook对象,用于记录每次迭代的统计数据:
stats = tools.Statistics(lambda ind: ind.fitness.values) stats.register("min", np.min) stats.register("avg", np.mean) logbook = tools.Logbook() logbook.header = ["gen", "evals"] + stats.fields -
程序的主循环包含一个外部循环,用于迭代生成/更新周期。在每次迭代中,有两个内部循环,每个循环遍历群体中的所有粒子。第一个循环,如下代码所示,评估每个粒子与要优化的函数的关系,并在必要时更新局部最佳和全局最佳:
particle.fitness.values = toolbox.evaluate(particle) # local best: if (particle.best is None or particle.best.size == 0 or particle.best.fitness < particle.fitness): particle.best = creator.Particle(particle) particle.best.fitness.values = particle.fitness.values # global best: if (best is None or best.size == 0 or best.fitness < particle.fitness): best = creator.Particle(particle) best.fitness.values = particle.fitness.values -
第二个内部循环调用update操作符。如前所述,该操作符使用惯性、认知力和社交力的结合来更新粒子的速度和位置:
toolbox.update(particle, best) -
在外部循环结束时,我们记录当前代的统计数据并将其打印出来:
logbook.record(gen=generation, evals=len(population), **stats.compile(population)) print(logbook.stream) -
一旦外部循环完成,我们会打印出在运行过程中记录的最佳位置的信息。这被认为是算法为当前问题找到的解决方案:
print("-- Best Particle = ", best) print("-- Best Fitness = ", best.fitness.values[0])
运行此程序后,我们得到如下输出:
gen evals min avg
0 20 8.74399 167.468
1 20 19.0871 357.577
2 20 32.4961 219.132
...
479 20 3.19693 316.08
480 20 0.00102484 322.134
481 20 3.32515 254.994
...
497 20 7.2162 412.189
498 20 6.87945 273.712
499 20 16.1034 272.385
-- Best Particle = [-3.77695478 -3.28649153]
-- Best Fitness = 0.0010248367255068806
这些结果表明,算法能够定位到一个最小值,大约位于 x=−3.77 和 y=−3.28。通过查看我们在过程中记录的统计数据,我们可以看到最佳结果是在第 480 代时获得的。还可以明显看出,粒子在过程中有较大的移动,并在运行期间围绕最佳结果进行振荡。
为了找到其他最小值位置,您可以使用不同的随机种子重新运行算法。您还可以像我们在第6 章中对Simionescu 函数所做的那样,对已找到的最小值周围的区域进行惩罚,优化连续函数。另一种方法是使用多个同时进行的群体来在同一次运行中找到多个最小值——我们鼓励您自己尝试一下(有关更多信息,请参阅进一步阅读部分)。
在接下来的章节中,我们将简要回顾扩展进化计算家族中的其他几种成员。
其他相关技术
除了我们目前所讨论的技术外,还有许多其他的求解和优化技术,其灵感来源于达尔文进化理论,以及各种生物系统和行为。以下小节将简要介绍其中几种技术。
进化策略
进化策略(ES)是一种强调变异而非交叉作为进化驱动因素的遗传算法。变异是自适应的,其强度会随着世代的推移进行学习。ES 中的选择操作符始终是基于排名而非实际的适应度值。该技术的一个简单版本叫做(1 + 1),它仅包括两个个体——一个父代和其变异后的后代。最优秀的个体将继续作为下一个变异后代的父代。在更一般的情况下,称为(1 + λ),它包括一个父代和λ个变异后的后代,最优秀的后代将继续作为下一个λ个后代的父代。一些新的算法变种包括多个父代,并且有一个交叉操作符。
差分进化
差分进化(DE)是遗传算法的一个专门变种,用于优化实数值函数。DE 与遗传算法的区别在于以下几个方面:
-
DE 种群始终表示为实值向量的集合。
-
与其用新一代完全替换当前的整代,DE 更倾向于在种群中不断迭代,每次修改一个个体,或者如果修改后的个体不如原个体,则保留原个体。
-
传统的交叉和变异操作符被专门的操作符所替代,从而通过随机选择的三只个体的值来修改当前个体的值。
蚁群优化
蚁群优化(ACO)算法的灵感来源于某些蚂蚁寻找食物的方式。蚂蚁们首先随机游走,当其中一只找到食物时,它们会回到巢穴并在路上留下信息素,标记出路径供其他蚂蚁参考。其他蚂蚁如果在相同位置找到食物,就会通过留下自己的信息素来加强这条路径。信息素标记随着时间的推移逐渐消失,因此较短的路径和经过频繁使用的路径更具优势。
ACO 算法使用人工蚂蚁在搜索空间中寻找最佳解的位置。这些“蚂蚁”会跟踪它们的当前位置以及它们在过程中找到的候选解。这些信息被后续迭代中的蚂蚁利用,从而帮助它们找到更好的解。通常,这些算法会与局部搜索方法结合使用,在找到感兴趣区域后激活局部搜索。
人工免疫系统
人工免疫系统(AIS)的灵感来源于哺乳动物适应性免疫系统的特性。这些系统能够识别和学习新威胁,并且应用所学知识,下一次类似威胁出现时能更快作出反应。
最近的 AIS 可以用于各种机器学习和优化任务,通常属于以下三大子领域之一:
-
克隆选择:这模仿了免疫系统选择最佳细胞来识别并消除入侵抗原的过程。细胞是从一池具有不同特异性的预存细胞中选出的,选中后被克隆以创建一个能够消除入侵抗原的细胞群体。这一范式通常应用于优化和模式识别任务。
-
负选择:这一过程旨在识别并删除可能攻击自体组织的细胞。这些算法通常用于异常检测任务,其中通过正常模式“负向”训练过滤器,从而能够检测异常模式。
-
免疫网络算法:这受到免疫系统使用特定类型抗体与其他抗体结合来调节的理论启发。在这种算法中,抗体代表网络中的节点,学习过程涉及在节点之间创建或删除边,从而形成一个不断演化的网络图结构。这些算法通常用于无监督机器学习任务,以及控制和优化领域。
人工生命
人工生命(ALife)不是进化计算的一个分支,而是一个更广泛的领域,涉及以不同方式模拟自然生命的系统和过程,如计算机仿真和机器人系统。
进化计算可以被视为人工生命(ALife)的应用,其中寻求优化某一适应度函数的种群可以看作是寻找食物的生物体。这些在第二章中描述的“共享和分配机制”,理解遗传算法的关键组件,直接源自于食物隐喻。
人工生命的主要分支如下:
-
软:代表基于软件的(数字)仿真
-
硬:代表基于硬件的(物理)机器人技术
-
湿:代表基于生化操作或合成生物学的技术
人工生命也可以被看作是人工智能的自下而上的对应物,因为人工生命通常基于生物环境、机制和结构,而不是高层次的认知。
总结
在本章中,你了解了进化计算的扩展家族以及其成员的一些常见特征。然后,我们使用遗传编程——遗传算法的一个特例——通过布尔逻辑构建块实现了偶校验检查任务。
接下来,我们通过使用 NEAT 技术,创建了一个神经网络实现,用于相同的偶校验任务。
接下来,我们创建了一个程序,利用粒子群优化技术来优化Himmelblau 函数。
我们以简要概述其他几种相关的解决问题技巧结束了这一章。
现在这本书已经结束,我想感谢你在我带领下共同探索遗传算法和进化计算的各个方面和应用案例。我希望你发现这本书既有趣又发人深省。正如本书所示,遗传算法及其相关技术可以应用于几乎任何计算和工程领域的众多任务,包括——很可能——你当前涉及的领域。记住,遗传算法开始处理问题所需要的只是表示解决方案的方式和评估解决方案的方式——或者比较两个解决方案的方式。既然这是人工智能和云计算的时代,你会发现遗传算法在这两者方面都有很好的应用,可以成为你解决新挑战时的强大工具。
进一步阅读
欲了解更多信息,请参考以下资源:
-
遗传编程:生物启发的机器 学习:
geneticprogramming.com/tutorial/ -
大数据中的人工智能,作者:Manish Kumar 和 Anand Deshpande,2018 年 5 月 21 日
-
Python 神经进化实践,作者:Iaroslav Omelianenko,2019 年 12 月 24 日
-
使用粒子群优化算法的多模态优化:CEC 2015 单目标多小 niche 优化竞赛:
ieeexplore.ieee.org/document/7257009


浙公网安备 33010602011771号