Python-遗传算法实用指南-四-

61 阅读1小时+

Python 遗传算法实用指南(四)

原文:annas-archive.org/md5/37b689acecddb360565f499dd5ebf6d0

译者:飞龙

协议:CC BY-NC-SA 4.0

第十四章:超越本地资源——在云端扩展遗传算法

本章在上一章的基础上进行扩展,上一章专注于使用多进程来提高遗传算法的性能。本章将遗传算法重构为客户端-服务器模型,其中客户端采用异步 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

请观看以下视频,查看代码演示:

packt.link/OEBOd

遗传算法性能的下一阶段——采用客户端-服务器架构

在前一章中,我们以多处理器方式实现了遗传算法,利用遗传算法的“极易并行化”特性,显著减少了每一代的计算时间。考虑到算法中最耗时的部分通常是适应度函数的计算,我们建立了一个模拟 CPU 密集型适应度函数的基准测试。通过利用 Python 内置的多进程功能以及名为 SCOOP 的外部库,我们成功地大幅减少了运行时间。

然而,这些实现仅限于在单台机器上运行的单一程序。在处理实际问题时,这种方法很可能会遇到机器资源的限制——不仅仅是可用的 CPU 核心,还包括内存和存储等基本资源。与我们的基准程序主要消耗 CPU 时间不同,实际的适应度函数可能对处理能力和内存都有较大的需求,这带来了巨大的挑战。

我们观察到 SCOOP 库支持通过利用网络中的其他机器来进行分布式计算。然而,在本章中,我们将探索另一种方法,即将程序分为两个独立的组件。这种方法将使我们在选择这些组件的运行平台时具有更大的灵活性。这样的策略为更丰富、更强大的计算解决方案打开了可能性,包括基于云的服务或专用硬件,从而克服了仅依赖网络机器的一些固有限制。

接下来的章节将详细介绍这一新结构的设计和实现过程,我们将分几个阶段进行。

实现客户端-服务器模型

我们的计划是将遗传算法的执行分为两个独立的部分——客户端和服务器,具体如下:

  • 客户端组件:客户端将集中管理进化逻辑,包括种群初始化、选择过程,以及交叉和变异等遗传操作。

  • 服务器组件:服务器将负责执行资源密集型的适应度函数计算。它将利用多进程技术充分发挥计算资源,绕过 Python 的全局解释器锁GIL)所带来的限制。

  • 客户端的异步 I/O 使用:此外,客户端将采用异步 I/O,它基于单线程、事件驱动模型运行。这种方法可以高效地处理 I/O 绑定的任务,使程序在等待 I/O 进程完成时能够同时处理其他操作。在服务器通信中采用异步 I/O 后,客户端能够发送请求并继续进行其他任务,而无需被动等待响应。这就像一个服务员将客人的订单送到厨房,然后在等待的同时接收另一桌的订单,而不是呆在厨房旁等待。类似地,我们的客户端通过在等待服务器响应时不阻塞主执行线程,优化了工作流程。

这个客户端-服务器模型及其操作动态在下图中有所说明:

图 14.1:所提议的客户端-服务器设置框图

图 14.1:所提议的客户端-服务器设置框图

在我们深入实现该模型之前,强烈建议为服务器组件设置一个单独的 Python 环境,具体内容将在下一节中介绍。

使用单独的环境

如我们在第三章《使用 DEAP 框架》中开始编码时所建议的,我们推荐为我们的程序创建一个虚拟环境,使用venvconda等工具。使用虚拟环境是 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,可以通过以下链接获取:

github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_14/server/fitness_evaluator.py

该程序扩展了 Flask 快速入门文档中概述的最小应用,详细内容见此处:

  1. 首先,我们导入 Flask 类并创建这个类的一个实例。name 参数表示应用模块或包的名称:

    from flask import Flask
    app = Flask(__name__)
    
  2. 接下来,我们定义welcome()函数,用于“健康检查”目的。该函数返回一个 HTML 格式的欢迎消息。当我们将浏览器指向服务器的根 URL 时,此消息将显示,确认服务器正在运行。@app.route("/") 装饰器指定该函数应由根 URL 触发:

    @app.route("/")
    def welcome():
        return "<p>Welcome to our Fitness Evaluation Server!</p>"
    
  3. 然后,我们重用上一章中的busy_wait()函数。此函数模拟一个计算密集型的适应性评估,持续时间由DELAY_SECONDS常量指定:

    def busy_wait(duration):
        current_time = time.time()
        while (time.time() < current_time + duration):
            pass
    
  4. 最后,我们定义实际的适应性评估函数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 显示欢迎消息

图 14.2:在服务器根 URL 显示欢迎消息

接下来,让我们通过访问以下网址来测试适应性函数:127.0.0.1:5000/one_max_fitness/1100110010

访问此 URL 会内部触发对oneMaxFitness()函数的调用,参数为1100110010。如预期的那样,在经过几秒钟的延迟后(由busy_wait()函数模拟处理时间),我们收到响应。浏览器显示数字5,表示输入字符串中 1 的总和,如图所示:

图 14.3:通过浏览器测试服务器的适应性函数

图 14.3:通过浏览器测试服务器的适应性函数

现在我们已经成功设置并验证了服务器,接下来让我们开始实现客户端。

创建客户端组件

要开始工作在客户端模块上,我们需要切换回原本用于基因算法程序的虚拟环境。这可以通过使用一个单独的终端或在 IDE 中打开一个新窗口来实现,在该环境中激活此虚拟环境。实现客户端模块的各个程序可以在以下位置找到:

github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/tree/main/chapter_14

我们将首先检查的程序是 01_one_max_client.py,它作为一个简单的(同步)客户端。该程序可以在以下位置找到:

github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_14/01_one_max_client.py

这个程序改编自上一章的 01_one_max_start.py——基本的 OneMax 问题求解器。为了支持将适应度计算委托给服务器,我们做了以下修改:

  1. Python 的 urllib 模块被导入。该模块提供了一套用于处理 URL 的函数和类,我们将使用这些工具向服务器发送 HTTP 请求并获取响应。

  2. BASE_URL 常量被定义为指向服务器的基础 URL:

    BASE_URL="http://127.0.0.1:5000"
    
  3. oneMaxFitness() 函数被重命名为 oneMaxFitness_client()。该函数将给定的个体——一个整数列表(01)——转换为一个单一的字符串。然后,它使用来自 urlliburlopen() 函数,将该字符串发送到服务器上的适应度计算端点,通过将基础 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.py02_one_max_async_client.py包含了发送适应度计算请求到服务器的函数,而elitism_async.py则管理主要的遗传算法循环,并负责调用该函数。以下小节将详细探讨这两个程序的细节。

更新 OneMax 求解器

我们从02_one_max_async_client.py开始,该程序可以在这里找到:

github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_14/02_one_max_async_client.py

与之前的同步程序01_one_max_client.py相比,以下差异被突出了:

  1. oneMaxFitness_client()函数已经重命名为async_oneMaxFitness_client()。除了individual,新函数还接收一个session参数,其类型为aiohttp.ClientSession;该对象负责管理异步请求并重用连接。函数签名前加上async关键字,标志着它是一个协程。这一标记使得该函数能够暂停执行,并将控制权交还给事件循环,从而实现请求的并发发送:

    async def async_oneMaxFitness_client(session, individual):
    
  2. 使用session对象发送 HTTP GET 请求到服务器。当收到响应时,响应内容将存储在response变量中:

    async with session.get(url) as response:
        ...
    
  3. main()函数现在利用异步函数调用,并用async关键字定义。

  4. 遗传算法主循环的调用现在使用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)
    
  5. main()函数的调用是通过asyncio.run()进行的。这种调用方法用于指定异步程序的主入口点。它启动并管理asyncio事件循环,从而允许异步任务的调度和执行:

    asyncio.run(main())
    
更新遗传算法循环

相应的程序elitism_async.py可以在这里找到:

github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_14/elitism_async.py

如前所述,该程序是熟悉的elitism.py的修改版,旨在异步执行遗传算法的主循环并管理对适应度函数的异步调用。以下是关键的修改部分:

  1. 首先,在循环开始之前会创建一个aiohttp.TCPConnector对象。这个对象负责创建和管理用于发送 HTTP 请求的 TCP 连接。limit参数在这里用来控制与服务器的并发连接数:

    connector = aiohttp.TCPConnector(limit=100)
    
  2. 接下来,创建一个aiohttp.ClientSession对象。这个会话对象用于异步发送 HTTP 请求,并在代码的其余部分中使用。它也会被传递到**async_oneMaxFitness_client()**函数中,在那里它用来向服务器发送请求。会话在整个循环中保持活跃,从而确保响应与相应的请求能够匹配:

    async with aiohttp.ClientSession(connector=connector) as session:
    
  3. 原先通过map()函数调用适应度评估函数,使用该函数对所有需要更新适应度值的个体(invalid_ind)进行evaluate操作,现在已被以下两行代码替代。这些代码行创建了一个名为evaluation_tasksTask对象列表,表示调度的异步适应度函数调用,然后等待所有任务完成:

    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 LinuxWSL)来使用,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可以在这里找到:

github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_14/server/fitness_evaluator_waitress.py

与原始程序fitness_evaluator.py的不同之处在此处突出显示:

  1. 首先,我们从 Waitress 模块中导入serve函数:

    from waitress import serve
    
  2. 然后,我们使用**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.amazon.com/free/

接下来,通过以下链接的说明安装 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 函数

图 14.4: Zappa 部署创建的 Lambda 函数

在我们的例子中,Zappa 创建的 Lambda 函数名为 server-dev。这个名字来源于应用程序所在目录的名称(server)和我们选择的生产环境名称(dev)的组合。

点击函数名称将带我们进入函数概览屏幕,在这里我们可以进一步探索详细信息,如函数的运行时环境、触发器、配置设置和监控指标,如下所示:

图 14.5:Lambda 函数概览屏幕

图 14.5:Lambda 函数概览屏幕

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

图 14.6:Zappa 部署创建的 API

图 14.6:Zappa 部署创建的 API

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

图 14.7:API 网关资源屏幕

图 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 框架文档:

    github.com/zappa/Zappa

  • Python asyncio 库:

    docs.python.org/3/library/asyncio.html

第五部分:相关技术

本部分探讨了遗传算法在图像处理中的应用,并介绍了其他生物启发的求解技术。第一章专门讲解了如何使用遗传算法进行图像重建,方法是通过半透明多边形重建图像,最终通过一个基于遗传算法的程序,使用这些技术重建一幅著名的画作。在接下来的章节中,讨论的范围扩展到包括遗传编程、增强拓扑神经进化NEAT)和粒子群优化,每个方法都通过基于 Python 的求解程序进行演示。最后,我们概述了该领域的其他几种计算范式,进一步扩展了我们对进化计算方法的理解。

本部分包含以下章节:

  • 第十五章*,遗传图像重建*

  • 第十六章*,其他进化与生物启发的计算技术*

第十五章:使用遗传算法进行图像进化重建

本章将尝试遗传算法在图像处理中的一种应用方式——用一组半透明的多边形重建图像。在这个过程中,我们将获得有用的图像处理经验,并且对进化过程有直观的了解。

我们将首先概述 Python 中的图像处理,并熟悉两个有用的库——PillowOpenCV-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

查看以下视频,查看代码的实际运行:

packt.link/OEBOd

用多边形重建图像

遗传算法在图像处理中的一个最引人注目的应用是通过一组半透明、重叠的形状重建给定图像。这种方法不仅在图像处理经验方面令人愉快且富有教育意义,还提供了进化过程的直观视觉呈现。此外,这些实验可能会加深对视觉艺术的理解,并有助于图像分析和压缩的进展。

在这些图像重建实验中——其中有许多变种可以在互联网上找到——通常使用一张熟悉的图像,往往是著名的画作或其一部分,作为参考。目标是通过拼接一组重叠的形状,通常是多边形,且这些形状具有不同的颜色和透明度,来构建一张相似的图像。

在这里,我们将通过使用遗传算法方法和 deap 库来应对这一挑战,就像我们在本书中为多种类型的问题所做的那样。然而,由于我们需要绘制图像并将其与参考图像进行比较,因此让我们先了解一下 Python 中的图像处理基础知识。

Python 中的图像处理

为了实现我们的目标,我们需要进行各种图像处理操作;例如,我们需要从头开始创建一张图像,在图像上绘制形状,绘制图像,打开图像文件,将图像保存到文件,比较两幅图像,并可能调整图像大小。在接下来的部分中,我们将探讨在使用 Python 时,如何执行这些操作。

Python 图像处理库

在众多可供 Python 程序员使用的图像处理库中,我们选择了两个最为突出的库。以下小节将简要讨论这些库。

Pillow 库

Pillow 是原始 Python Imaging LibraryPIL)的一个当前维护版本。它支持打开、处理和保存多种格式的图像文件。由于它允许我们处理图像文件、绘制形状、控制透明度并操作像素,我们将使用它作为重建图像的主要工具。

该库的主页可以在这里找到: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/

使用多边形绘制图像

要从头绘制一张图像,我们可以使用 PillowImageImageDraw 类,代码如下:

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](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/09e72ece049e4ca982b0eb8ad371de77~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1770948516&x-signature=Xs9rwAt5MdcfglRq%2BcEpFGCnIgw%3D)

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](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/29f0b38afe714516aa5d49d4f0c68d3a~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1770948516&x-signature=lOTPV6g64ek8Oa%2Fa%2BPMkmA0j7wg%3D)

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](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/0b258277ce4349d7803d512c6db5af3a~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1770948516&x-signature=02ock9Pm3envD%2B9dj6%2BoNYbdctA%3D)

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](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/e9276ba2cb7b4b3082c15dd0d71cfd25~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1770948516&x-signature=7qsPbLfJMQ0YLRJeWzrUY0J9vyY%3D)

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](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/9a2da92c670a4cd0bcfdc1ed15850301~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1770948516&x-signature=FaxMphJotpZTV7Nxuse%2FKU5fbAM%3D)

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](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/d82fa2b26f264881a557e8a12557f83b~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1770948516&x-signature=KwcwvDdvxfl2TqHXrP2P0xMOV1I%3D)

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](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/edbefc912a874a0bbb5aecc9f0b6dc7f~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1770948516&x-signature=XQw%2B%2FztmTRkLQWj2xn6JOei6RzA%3D)

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](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/640bab19f4ce40c3916770c0431da2ad~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1770948516&x-signature=muVuXSSoHik3xvp9eyKZRr6852o%3D)

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

查看以下视频,看看代码的实际应用:

packt.link/OEBOd

进化计算与生物启发式计算

本书中,我们已经介绍了名为遗传算法的求解技术,并将其应用于多种类型的问题,包括组合优化约束满足连续函数优化,以及机器学习人工智能。然而,正如我们在第一章《遗传算法简介》中提到的那样,遗传算法只是一个更大算法家族——进化计算中的一支。这个家族包括各种相关的问题解决和优化技术,所有这些技术都从查尔斯·达尔文的自然进化理论中汲取灵感。

这些技术共享的主要特征如下:

  • 起点是一个初始集(种群)的候选解。

  • 候选解(个体)会通过迭代更新,生成新的代。

  • 创建新一代涉及淘汰不太成功的个体(选择),并对一些个体引入小的随机变化(突变)。也可以应用其他操作,如与其他个体的交互(交叉)。

  • 结果是,随着代数的增加,种群的适应度提高;换句话说,候选解在解决当前问题上变得更加有效。

更广泛地说,由于进化计算技术基于各种生物系统或行为,它们通常与被称为生物启发计算的算法家族有所重叠。

在接下来的章节中,我们将介绍一些进化计算和生物启发计算中最常用的成员——有些会详细介绍,而其他则仅简要提及。我们将从详细介绍一个有趣的技术开始,它使我们能够进化实际的计算机程序:遗传编程

遗传编程

遗传编程是遗传算法的一种特殊形式——也就是我们在本书中应用的技术。在这种特殊情况下,我们正在进化的候选解——或者说个体——是计算机程序,因此得名。换句话说,当我们应用遗传编程时,我们在进化计算机程序,以找到能够出色完成特定任务的程序。

如你所记得,遗传算法使用候选解的表示,通常称为染色体。这个表示受基因操作符的作用,即选择交叉突变。将这些操作符应用于当前代时,结果是产生一个新的解代,预计比前代产生更好的结果。在我们到目前为止所研究的大多数问题中,这种表示通常是某种类型值的列表(或数组),如整数、布尔值或浮点数。然而,为了表示程序,我们通常使用树结构,如以下图示所示:

图 16.1:简单程序的树结构表示

图 16.1:简单程序的树结构表示

来源:commons.wikimedia.org/wiki/File:Genetic_Program_Tree.png

图片来自 Baxelrod。

前面的树形结构表示了树下方展示的计算过程。这个计算等同于一个接受两个参数 XY 的短程序(或函数),并根据它们的值返回一个特定的输出。为了创建和进化这样的树结构,我们需要定义两个不同的集合:

  • 终端,或者是树的叶节点。这些是可以在树中使用的参数和常量值。在我们的示例中,XY 是参数,而 2.2117 是常量。在树创建时,常量也可以在某个范围内随机生成。

  • 基本操作符,或者是树的内部节点。这些是接受一个或多个参数并生成单一输出值的函数(或运算符)。在我们的示例中,+-、****、÷ 是接受两个参数的基本操作符,而 cos 是接受一个参数的基本操作符。

第二章,《理解遗传算法的关键组件》中,我们展示了遗传操作符单点交叉如何作用于二进制值列表。交叉操作通过切割每个父节点的一部分并交换父节点之间的切割部分,创建了两个后代。类似地,树形表示的交叉操作符可以从每个父节点中分离出一个子树(一个分支或一组分支),并交换这些被分离的分支来创建后代树,示例如下图所示:

图 16.2:表示程序的两个树结构之间的交叉操作

图 16.2:表示程序的两个树结构之间的交叉操作

来源:commons.wikimedia.org/wiki/File:GP_crossover.png

图片由 U-ichi 提供

在这个例子中,顶行的两个父节点有交换了子树,形成了第二行的两个后代。交换的子树用矩形框标出。

类似地,变异操作符旨在对单个个体引入随机变化,它的实现方式是从候选解中选择一个子树,并将其替换为一个随机生成的子树。

我们在本书中一直使用的 deap 库原生支持遗传编程。在下一节中,我们将使用该库实现一个简单的遗传编程示例。

遗传编程示例——偶校验检查

对于我们的示例,我们将使用遗传编程创建一个实现偶校验检查的程序。在此任务中,输入的可能值为 0 或 1。若输入中值为 1 的数量为奇数,则输出值应为 1,从而得到一个偶数个 1 的总数;否则,输出值应为 0。下表列出了三输入情况下,输入值的各种可能组合及其对应的偶校验输出值:

in_0in_1in_2偶校验
0000
0011
0101
0110
1001
1010
1100
1111

表 16.1:三输入偶校验的真值表

这种表格通常被称为真值表。从这个真值表可以看出,偶校验经常作为基准使用的原因之一是,输入值的任何单一变化都会导致输出值发生变化。

偶校验还可以通过逻辑门来表示,例如ANDORNOT和异或(XOR)。NOT门接受一个输入并将其反转,而其他三种门类型每种都接受两个输入。为了使输出为 1,AND门要求两个输入都为 1,OR门要求至少一个输入为 1,而XOR门要求恰好一个输入为 1,如下表所示:

in_0in_1ANDORXOR
00000
01011
10011
11110

表 16.2:两输入的 AND、OR 和 XOR 操作的真值表

实现三输入偶校验有多种可能的方法。最简单的方法是使用两个XOR门,如下图所示:

图 16.3:使用两个 XOR 门实现的三输入偶校验

图 16.3:使用两个 XOR 门实现的三输入偶校验

在接下来的小节中,我们将使用遗传编程创建一个小程序,该程序通过使用ANDORNOTXOR逻辑运算实现偶校验。

遗传编程实现

为了进化出实现偶校验逻辑的程序,我们创建了一个基于遗传编程的 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找到。

由于遗传编程是遗传算法的一种特例,如果你已经浏览过本书前几章中展示的程序,那么这个程序的很多部分应该会对你来说非常熟悉。

以下步骤描述了该程序的主要部分:

  1. 我们首先设置与问题相关的常量值。这里,NUM_INPUTS决定了偶校验检查器的输入数量。为了简化,我们使用3作为值;但也可以设置更大的值。NUM_COMBINATIONS常量表示输入值的可能组合数,它类似于我们之前看到的真值表中的行数:

    NUM_INPUTS = 3
    NUM_COMBINATIONS = 2 ** NUM_INPUTS
    
  2. 接下来是我们之前多次见过的熟悉的遗传算法常量:

    POPULATION_SIZE = 60
    P_CROSSOVER = 0.9
    P_MUTATION = 0.5
    MAX_GENERATIONS = 20
    HALL_OF_FAME_SIZE = 10
    
  3. 然而,遗传编程需要一些额外的常量,这些常量指的是候选解的树表示形式。它们在以下代码中定义。我们将在检查本程序的其余部分时看到它们是如何使用的:

    MIN_TREE_HEIGHT = 3
    MAX_TREE_HEIGHT = 5
    MUT_MIN_TREE_HEIGHT = 0
    MUT_MAX_TREE_HEIGHT = 2
    LIMIT_TREE_HEIGHT = 17
    
  4. 接下来,我们计算真值表的偶校验,以便在需要检查给定候选解的准确性时可以作为参考。parityIn矩阵表示真值表的输入列,而parityOut向量表示输出列。Python 的itertools.product()函数是嵌套for循环的优雅替代,它可以遍历所有输入值的组合:

    parityIn = list(itertools.product([0, 1], repeat=NUM_INPUTS))
    parityOut = [sum(row) % 2 for row in parityIn]
    
  5. 现在,是时候创建原始元素集合了——也就是将用于我们进化程序的运算符。第一个声明使用以下三个参数创建一个集合:

    • 使用集合中的原始元素生成的程序名称(在这里,我们称之为main

    • 程序的输入数量

    • 用于命名输入的前缀(可选)

    这三个参数用于创建以下原始元素集合:

    primitiveSet = gp.PrimitiveSet("main", NUM_INPUTS, "in_")
    
  6. 现在,我们用将作为程序构建块的各种函数(或运算符)填充原始集合。对于每个运算符,我们将引用实现它的函数以及它期望的参数个数。尽管我们可以为此定义自己的函数,但在本例中,我们利用了现有的 Python operator模块,它包含了许多有用的函数,包括我们所需的逻辑运算符:

    primitiveSet.addPrimitive(operator.and_, 2)
    primitiveSet.addPrimitive(operator.or_, 2)
    primitiveSet.addPrimitive(operator.xor, 2)
    primitiveSet.addPrimitive(operator.not_, 1)
    
  7. 以下定义设置了要使用的终止值。如前所述,这些是可以作为树的输入值的常量。在我们的例子中,使用01作为值是合适的:

    primitiveSet.addTerminal(1)
    primitiveSet.addTerminal(0)
    
  8. 由于我们的目标是创建一个实现偶校验真值表的程序,我们将尽量减少程序输出与已知输出值之间的差异。为此,我们将定义一个单一的目标——即最小化适应度策略:

    creator.create("FitnessMin", base.Fitness, weights=(-1.0,))
    
  9. 现在,我们将创建Individual类,基于deap库提供的PrimitiveTree类:

    creator.create("Individual", gp.PrimitiveTree,\
        fitness=creator.FitnessMin)
    
  10. 为了帮助我们构建种群中的个体,我们将创建一个辅助函数,使用之前定义的原始集合生成随机树。在这里,我们利用了deap提供的**genFull()**函数,并为其提供了原始集合,以及定义生成树的最小和最大高度的值:

    toolbox.register("expr",
                      gp.genFull,
                      pset=primitiveSet,
                      min_=MIN_TREE_HEIGHT,
                      max_=MAX_TREE_HEIGHT)
    
  11. 接下来定义了两个操作符,第一个通过前面的辅助操作符创建个体实例。另一个生成这样个体的列表:

    toolbox.register("individualCreator",
                      tools.initIterate,
                      creator.Individual,
                      toolbox.expr)
    toolbox.register("populationCreator",
                      tools.initRepeat,
                      list,
                      toolbox.individualCreator)
    
  12. 接下来,我们创建一个操作符,用于编译给定的原始树为 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))
    
  13. 然后,我们必须指示遗传编程算法使用**getCost()**函数进行适应度评估。该函数返回我们刚才看到的奇偶错误,作为元组形式,这是底层进化算法所需的:

    def getCost(individual):
        return parityError(individual),
    toolbox.register("evaluate", getCost)
    
  14. 现在是选择我们的遗传操作符的时候了,从选择操作符(别名为select)开始。对于遗传编程,这个操作符通常是我们在本书中一直使用的锦标赛选择。在这里,我们使用的锦标赛大小为2

    toolbox.register("select", tools.selTournament, tournsize=2)
    
  15. 至于交叉操作符(别名为mate),我们将使用 DEAP 提供的专用遗传编程**cxOnePoint()**操作符。由于进化程序由树表示,这个操作符接受两个父树,并交换它们的部分内容,从而创建两个有效的后代树:

    toolbox.register("mate", gp.cxOnePoint)
    
  16. 接下来是变异操作符,它引入对现有树的随机变化。变异定义为两个阶段。首先,我们指定一个辅助操作符,利用由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)
    
  17. 为了防止种群中的个体树木过度增长,可能包含过多的原始元素,我们需要引入膨胀控制措施。我们可以通过使用 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))
    
  18. 程序的主循环与我们在前几章中看到的非常相似。在创建初始种群、定义统计度量和创建 HOF 对象之后,我们调用进化算法。像之前做过的多次一样,我们必须采用精英方法,即将 HOF 成员——当前最优个体——始终传递给下一代,保持不变:

    population, logbook = elitism.eaSimpleWithElitism(
        population,
        toolbox,
        cxpb=P_CROSSOVER,
        mutpb=P_MUTATION,
        ngen=MAX_GENERATIONS,
        stats=stats,
        halloffame=hof,
        verbose=True)
    
  19. 在运行结束时,我们打印出最佳解决方案,以及用于表示它的树的高度和长度——即树中包含的运算符总数:

    best = hof.items[0]
    print("-- Best Individual = ", best)
    print(f"-- length={len(best)}, height={best.height}")
    print("-- Best Fitness = ", best.fitness.values[0])
    
  20. 最后我们需要做的事情是绘制表示最佳解决方案的树的图形插图。为此,我们必须使用图形和网络库NetworkXnx),它在第五章《约束满足》中有所介绍。我们首先调用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)
    
  21. 接下来,我们绘制节点、边和标签。由于该图的布局不是经典的层次树结构,我们必须通过将顶部节点着色为红色并放大它来区分它:

    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:表示初始程序找到的奇偶校验解决方案的图表

图 16.4:表示初始程序找到的奇偶校验解决方案的图表

如前所述,图中的红色节点表示程序树的顶部,它映射到表达式中的第一个XOR操作。

这个相对复杂的图表的原因是使用更简单的表达式没有优势。只要它们符合树高度的限制,被评估的表达式不会因复杂性而受到惩罚。在下一个子章节中,我们将通过对程序进行小的修改来尝试改变这种情况,期望以更简洁的解决方案实现相同的结果——偶校验检查的实现。

简化解决方案

在我们刚刚看到的实现中,已经采取了措施来限制表示候选解的树的大小。然而,我们找到的最佳解似乎过于复杂。强制算法生成更简单结果的一种方法是对复杂性施加小的成本惩罚。这种惩罚应该足够小,以避免偏好于未能解决问题的更简单解决方案。相反,它应该作为两个良好解决方案之间的决胜局,从而优先选择更简单的解决方案。这种方法已经在位于github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_16/02_gp_even_parity_reduced.py02_gp_even_parity_reduced.py Python 程序中实施。

这个程序几乎与之前的程序相同,除了几个小的变化:

  1. 主要的变化是引入到成本函数中,该算法试图最小化。在原始计算的错误之上,增加了依赖于树高度的小惩罚措施:

    def getCost(individual):
        return parityError(individual) +
                   individual.height / 100,
    
  2. 唯一的其他变化发生在运行结束后,在打印找到的最佳解决方案之后。在这里,除了打印健身价值外,我们还打印了实际的奇偶错误,而没有出现在健身中的惩罚:

    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:代表由修改后程序找到的奇偶校验解的绘图

图 16.5:代表由修改后程序找到的奇偶校验解的绘图

尽管遗传编程被认为是遗传算法的一个子集,但下一节描述了一种更专门的进化计算形式 - 专门用于创建神经网络架构。

NEAT

第九章深度学习网络的架构优化,我们展示了如何使用简单的遗传算法来找到适合特定任务的前馈神经网络(也称为多层感知器MLP)的最佳架构。为此,我们限制了三个隐藏层,并使用了一个具有每个层占位符的固定大小染色体来编码每个网络,其中 0 或负值表示该层不存在。

将这个想法进一步发展,NEAT是一种专门用于更灵活和渐进创建神经网络的进化技术,由Kenneth StanleyRisto 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

程序

以下步骤描述了该程序的主要部分:

  1. 与遗传编程示例类似,我们将首先设置与问题相关的常量值。NUM_INPUTS决定了偶校验检查器的输入数量。

  2. 由于我们希望在程序结束时保存一个包含最佳解决方案的网络结构的图像,因此需要确保已创建一个用于存放图像的文件夹:

    IMAGE_PATH = os.path.join(
        os.path.dirname(os.path.realpath(__file__)),
        "images")
    if not os.path.exists(IMAGE_PATH):
        os.makedirs(IMAGE_PATH)
    
  3. 现在,我们必须使用PyGame库的功能设置图形显示,以实时“动画”方式展示算法的进度:

    pg.init()
    screen = pg.display.set_mode((400, 400))
    screen.fill(colors['lightblue'])
    
  4. 接下来,我们必须为 NEAT 算法设置几个选项:

    • 我们网络的输入数量(这将与NUM_INPUTS相同)。

    • 输出的数量(在我们的例子中为 1)。

    • 种群大小(在我们的例子中为 150)。

    • 适应度阈值。如果最佳解决方案超过此值,算法就认为问题已解决并停止。由于最佳适应度等于真值表中的行数(表示我们对所有行得出了正确的结果),因此我们必须将阈值设置为一个略低于该值的数字:

      Options.set_options(NUM_INPUTS, 1, 150, 2**NUM_INPUTS - 0.1)
      
  5. 接下来,我们必须在实现所需偶校验的输入和输出时计算parityInparityOut,这与我们在遗传编程示例中所做的类似:

    parityIn = list(itertools.product([0, 1], repeat=NUM_INPUTS))
    parityOut = [sum(row) % 2 for row in parityIn]
    
  6. 现在,是时候定义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
    
  7. 接下来是另一个工具函数,draw_current()。它通过调用neatpy库的**draw_brain_pygame()绘制当前最佳解的架构(节点和连接);此外,它还通过使用draw_species_bar_pygame()**函数绘制当前物种的状态,展示了物种分化机制。

  8. 创建初始种群后,我们进入了 NEAT 算法的主循环。得益于neatpy库的简洁性,这个循环非常简明。它从对当前种群进行评分开始,这也是进化算法中的常见步骤:

    for nn in p.pool:
        nn.fitness = parityScore(nn)
    
  9. 主循环通过调用库中的**epoch()函数继续进行,这个函数执行一次 NEAT 进化步骤,产生新的种群。然后,它会打印当前种群,并通过调用draw_current()**绘制当前最佳个体及物种状态。

  10. 一旦循环退出,结果会被打印出来,真值表会被检查,最新的图形也会保存为图片文件。

    在运行程序时,包含网络可视化和物种演化的图形会出现,并在每一代更新自身,从而创建出一个“动画”视图来显示状态。下图包含了在运行过程中捕捉的四个“快照”:

图 16.6:三输入偶数奇偶校验问题的 NEAT 解的演化阶段

图 16.6:三输入偶数奇偶校验问题的 NEAT 解的演化阶段

这些快照展示了网络如何从仅有输入层和输出层节点及一个物种开始,然后发展出多个物种,接着增加了一个隐藏层节点,再增加第二个隐藏层节点。

在运行结束时,程序会将最后一个快照保存为图片,保存在images文件夹中。如下所示:

图 16.7:三输入偶数奇偶校验问题的 NEAT 解的最终演化阶段

图 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 函数

图 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

以下步骤描述了程序的主要部分:

  1. 我们首先设置程序中将使用的各种常量。首先,我们有当前问题的维度—在我们这里是2—它决定了每个粒子的位置速度的维度。接下来是种群大小——即群体中粒子的总数——以及运行算法的代数或迭代次数:

    DIMENSIONS = 2
    POPULATION_SIZE = 20
    MAX_GENERATIONS = 500
    
  2. 然后是一些额外的常量,它们影响粒子的创建和更新。我们将在分析程序的其余部分时看到它们的作用:

    MIN_START_POSITION, MAX_START_POSITION = -5, 5
    MIN_SPEED, MAX_SPEED = -3, 3
    MAX_LOCAL_UPDATE_FACTOR = MAX_GLOBAL_UPDATE_FACTOR = 2.0
    
  3. 由于我们的目标是定位Himmelblau 函数的最小值,我们需要定义一个单一目标——即,最小化适应度策略:

    creator.create("Particle class creator looks as follows:
    
    

    creator.create("Particle",

    np.ndarray,

    fitness=creator.FitnessMin,

    speed=None,

    best=None)

  4. 为了帮助我们构建种群中的单个粒子,我们需要定义一个辅助函数,用于创建并初始化一个随机粒子。我们将使用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
    
  5. 该函数用于定义创建粒子实例的操作符。这反过来被种群创建操作符使用:

    toolbox.register("particleCreator", createParticle)
    toolbox.register("populationCreator",
                      tools.initRepeat,
                      list,
                      toolbox.particleCreator)
    
  6. 接下来是作为算法核心的方法,在我们这里,ndarray类型是二维的,计算是逐元素进行的,每次处理一个维度。

  7. 更新后的粒子速度实际上是粒子原始速度(代表惯性)、粒子已知的最佳位置(认知力)和整个群体的最佳已知位置(社交力)的结合:

    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)
    
  8. updateParticle()方法继续执行,确保新的速度不超过预设的限制,并使用更新后的速度更新粒子的位置。如前所述,位置速度都是ndarray类型,并且每个维度都有单独的组件:

    particle.speed = np.clip(particle.speed, MIN_SPEED, MAX_SPEED)
    particle[:] = particle + particle.speed
    
  9. 然后,我们必须将**updateParticle()**方法注册为工具箱操作符,该操作符将在后续的主循环中使用:

    toolbox.register("update", updateParticle)
    
  10. 我们仍然需要定义要优化的函数——在我们的例子中是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)
    
  11. 现在我们终于到达了**main()**方法,可以通过创建粒子群体来开始它:

    population = toolbox.populationCreator(
        n=POPULATION_SIZE)
    
  12. 在开始算法的主循环之前,我们需要创建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
    
  13. 程序的主循环包含一个外部循环,用于迭代生成/更新周期。在每次迭代中,有两个内部循环,每个循环遍历群体中的所有粒子。第一个循环,如下代码所示,评估每个粒子与要优化的函数的关系,并在必要时更新局部最佳全局最佳

    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
    
  14. 第二个内部循环调用update操作符。如前所述,该操作符使用惯性认知力社交力的结合来更新粒子的速度和位置:

    toolbox.update(particle, best)
    
  15. 在外部循环结束时,我们记录当前代的统计数据并将其打印出来:

    logbook.record(gen=generation,
                   evals=len(population),
                   **stats.compile(population))
    print(logbook.stream)
    
  16. 一旦外部循环完成,我们会打印出在运行过程中记录的最佳位置的信息。这被认为是算法为当前问题找到的解决方案:

    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