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

92 阅读1小时+

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

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第五章:约束满足

在本章中,您将学习如何利用遗传算法解决约束满足问题。我们将从描述约束满足的概念开始,并讨论它如何应用于搜索问题和组合优化。然后,我们将通过几个实际示例,展示约束满足问题及其基于 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=2n=3 的情况外,该问题都有解。对于最初的八皇后问题,有 92 种解,或者如果将对称解视为相同,则有 12 种唯一解。以下是其中一种解:

图 5.1:八皇后谜题的 92 种可能解之一

图 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]表示的皇后排列示意图

图 5.2:由列表[3, 2, 0, 1]表示的皇后排列示意图

同样,索引的另一种排列可能如下所示:

[1, 3, 0, 2]

该排列表示以下图示中的候选解:

图 5.3:由列表[1, 3, 0, 2]表示的皇后排列示意图

图 5.3:由列表[1, 3, 0, 2]表示的皇后排列示意图

以这种方式表示的候选解中唯一可能的约束冲突是皇后对之间共享的对角线。

例如,我们讨论的第一个候选解包含两个违反约束条件的情况,如下所示:

图 5.4:由列表[3, 2, 0, 1]表示的皇后排列示意图,标明了约束条件冲突

图 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]表示的八皇后排列示意图

图 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 的简单遗传流程创建的精英版本。

以下步骤描述了我们解决方案的主要部分:

  1. 我们的程序通过使用我们希望解决的问题的大小,创建NQueensProblem类的实例来开始:

    nQueens = queens.NQueensProblem(NUM_OF_QUEENS)
    
  2. 由于我们的目标是最小化冲突数量(希望达到 0),我们定义了一个单一目标,即最小化适应度策略:

    creator.create("FitnessMin", base.Fitness, weights=(-1.0,))
    
  3. 由于解决方案由一个有序的整数列表表示,其中每个整数表示一个皇后的列位置,我们可以使用以下工具箱定义来创建初始种群:

    # 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)
    
  4. 实际的适应度函数设置为计算由于皇后在棋盘上的放置所引起的冲突数量,每个解决方案代表一个冲突:

    def getViolationsCount(individual):
        return nQueens.getViolationsCount(individual),
    toolbox.register("evaluate", getViolationsCount)
    
  5. 至于遗传操作符,我们使用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))
    
  6. 此外,我们继续使用精英主义方法,其中名人堂HOF)成员——当前最好的个体——始终不受影响地传递到下一代。正如我们在上一章中发现的那样,这种方法与大小为 2 的锦标赛选择非常匹配:

    population, logbook = elitism.eaSimpleWithElitism(population,
        toolbox,
        cxpb=P_CROSSOVER,
        mutpb=P_MUTATION,
        ngen=MAX_GENERATIONS,
        stats=stats,
        halloffame=hof,
        verbose=True)
    
  7. 由于每个 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,这意味着没有违反规则。此外,最佳解的打印输出表明在运行过程中找到的八个不同解,这些解的编号是07,它们的适应度值都是0。下一个解的适应度值已经为1,表示存在违规。

程序生成的第一个图表展示了根据找到的第一个有效解所定义的16x16国际象棋棋盘上 16 个皇后的位置,[7, 2, 8, 14, 9, 4, 0, 15, 6, 11, 13, 1, 3, 5, 10, 12]:

图 5.6:程序找到的有效 16 皇后排列的图示

图 5.6:程序找到的有效 16 皇后排列的图示

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

图 5.7:程序解决 16 皇后问题的统计数据

图 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 背包问题,组合优化

我们解决方案的主要部分在以下步骤中描述:

  1. 我们的程序首先创建一个NurseSchedulingProblem类的实例,并为hardConstraintPenalty设置期望值,该值由HARD_CONSTRAINT_PENALTY常量设置:

    nsp = nurses.NurseSchedulingProblem(HARD_CONSTRAINT_PENALTY)
    
  2. 由于我们的目标是最小化成本,我们必须定义一个单一目标,即最小化适应度策略:

    creator.create("FitnessMin", base.Fitness, weights=(-1.0,))
    
  3. 由于解表示为 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)
    
  4. 实际的适应度函数设置为计算排班中各种违规的成本,由每个解决方案表示:

    def getCost(individual):
        return nsp.getCost(individual),
    toolbox.register("evaluate", getCost)
    
  5. 至于遗传算子,我们必须使用2的锦标赛选择,并结合两点交叉和翻转位变异,因为这适用于二进制列表:

    toolbox.register("select", tools.selTournament, tournsize=2)
    toolbox.register("mate", tools.cxTwoPoint)
    toolbox.register("mutate", tools.mutFlipBit, indpb=1.0/len(nsp))
    
  6. 我们继续使用精英主义方法,其中 HOF 成员——当前最优秀的个体——始终被无修改地传递到下一代:

    population, logbook = elitism.eaSimpleWithElitism(
        population, toolbox, cxpb=P_CROSSOVER, \
        mutpb=P_MUTATION, ngen=MAX_GENERATIONS, \
        stats=stats, halloffame=hof, verbose=True)
    
  7. 当算法完成时,我们打印出找到的最佳解决方案的详细信息:

    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:解决护士排班问题的程序统计数据

图 5.8:解决护士排班问题的程序统计数据

看起来,在我们的情况下,仅对硬约束违规设置 10 倍的惩罚就足够了。在其他问题中,可能需要更高的值。建议你通过改变问题的定义以及遗传算法的设置进行实验。

我们刚才看到的软约束和硬约束之间的权衡将在我们接下来的任务——图着色问题中发挥作用。

求解图着色问题

在图论的数学分支中,是一个结构化的对象集合,表示这些对象对之间的关系。图中的对象表现为顶点(或节点),而对象对之间的关系则通过表示。常见的图形表示方式是将顶点画为圆圈,边画为连接线,如下图所示,这是一幅彼得森图,以丹麦数学家尤利乌斯·彼得森的名字命名:

图 5.9:彼得森图

图 5.9:彼得森图

来源:commons.wikimedia.org/wiki/File:Petersen1_tiny.svg

图片来自 Leshabirukov。

图是非常有用的对象,因为它们可以表示并帮助我们研究各种现实生活中的结构、模式和关系,例如社交网络、电网布局、网站结构、语言构成、计算机网络、原子结构、迁徙模式等。

图着色任务是为图中的每个节点分配颜色,确保任何一对连接(相邻)节点不会共享相同的颜色。这也被称为图的正确着色

下图显示了相同的彼得森图,但这次进行了正确的着色:

图 5.10:彼得森图的正确着色

图 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]

让我们详细讨论一下这里的内容:

  • 使用了四种颜色,分别由整数0123表示

  • 图的第一个、第七个和第十个节点被涂上了第一种颜色(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:五种颜色错误着色的彼得森图

图 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

由于我们为这个问题选择的解决方案表示方式是一个整数列表,我们需要扩展遗传算法方法,改为使用二进制列表。

以下步骤描述了我们解决方案的主要步骤:

  1. 程序通过创建一个GraphColoringProblem类的实例开始,使用要解决的NetworkX图(在此案例中为熟悉的彼得森图)和所需的hardConstraintPenalty值,该值由HARD_CONSTRAINT_PENALTY常量设置:

    gcp = graphs.GraphColoringProblem(nx.petersen_graph(), 
        HARD_CONSTRAINT_PENALTY)
    
  2. 由于我们的目标是最小化成本,我们将定义一个单一目标,即最小化适应度策略:

    creator.create("FitnessMin", base.Fitness, weights=(-1.0,))
    
  3. 由于解决方案是由表示参与颜色的整数值列表表示的,我们需要定义一个随机生成器,它会生成一个介于 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)
    
  4. 适应度评估函数被设置为通过调用GraphColoringProblem类的**getCost()**方法,计算与每个解决方案相关的着色违规成本和使用颜色数量的组合:

    def getCost(individual):
        return gcp.getCost(individual),
    toolbox.register("evaluate", getCost)
    
  5. 至于遗传操作符,我们仍然可以使用我们为二进制列表使用的相同选择交叉操作;然而,变异操作需要改变。用于二进制列表的翻转位变异会在 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))
    
  6. 我们继续使用精英策略,即 HOF 成员——当前最优秀的个体——总是无任何改动地传递到下一代:

    population, logbook = elitism.eaSimpleWithElitism(\
        population, toolbox, cxpb=P_CROSSOVER, \
        mutpb=P_MUTATION, ngen=MAX_GENERATIONS, \
        stats=stats, halloffame=hof, verbose=True)
    
  7. 当算法结束时,我们会打印出找到的最佳解决方案的详细信息,然后再绘制图表。

    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

这意味着算法能够使用三种颜色为图着色,分别由整数056表示。如前所述,实际的整数值并不重要——重要的是它们之间的区分。三是已知的 Petersen 图的色数。

前面的代码生成了以下图表,说明了解决方案的有效性:

图 5.12:由程序使用三种颜色正确着色的 Petersen 图的绘图

图 5.12:由程序使用三种颜色正确着色的 Petersen 图的绘图

下图展示了每代的最小和平均适应度,表明算法很快就达到了解决方案,因为 Petersen 图相对较小:

图 5.13:程序解决 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:程序用五种颜色正确着色的梅西尔斯基图

图 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 仓库中找到,链接如下:

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

查看以下视频,看看代码的实际效果:

packt.link/OEBOd

用于实数的染色体和遗传操作符

在前几章中,我们关注的是本质上处理状态评估和状态间转换的搜索问题。因此,这些问题的解决方案最好用二进制或整数参数的列表(或数组)表示。与此不同,本章讨论的是解空间为连续的问题,即解由实数(浮动点数)构成。如我们在第二章中提到的,理解遗传算法的关键组成部分,发现用二进制或整数列表表示实数远非理想,反而使用实值数的列表(或数组)被认为是一种更简单、更好的方法。

重述第二章中的示例,如果我们有一个涉及三个实值参数的问题,那么染色体将如下所示:

[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() 实现了 正态分布变异,使用 musigma 参数分别作为均值和标准差的值。

此外,由于连续函数的优化通常在特定的 有界区域 内进行,而不是在整个空间中,DEAP 提供了几个接受边界参数的操作符,确保生成的个体位于这些边界内:

  • cxSimulatedBinaryBounded()cxSimulatedBinary() 操作符的有界版本,接受 lowup 参数,分别作为搜索空间的下界和上界。

  • mutPolynomialBounded() 是一个有界的 变异 操作符,它使用多项式函数(而不是高斯函数)作为概率分布。该操作符还接受 lowup 参数,分别作为搜索空间的下界和上界。此外,它使用 eta 参数作为拥挤因子,较高的值会使变异体接近原始值,而较小的值则会生成与原始值差异较大的变异体。

在下一节中,我们将演示在优化经典基准函数时使用有界操作符的方法。

优化 Eggholder 函数

Eggholder 函数,如下图所示,常被用作函数优化算法的基准。由于具有大量局部最小值,这使得找到该函数的单一 全局最小值 成为一项艰巨的任务,正因如此,它呈现出蛋托形状:

图 6.1: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 程序中:

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

以下步骤突出显示了该程序的主要部分:

  1. 程序开始时设置函数常量,即输入维度的数量(2,因为该函数在x-y平面上定义),以及前面提到的搜索空间边界:

    DIMENSIONS = 2  # number of dimensions
    # boundaries, same for all dimensions
    BOUND_LOW, BOUND_UP = -512.0, 512.0  
    
  2. 由于我们处理的是受特定边界限制的浮动点数,接下来我们定义一个辅助函数来生成在给定范围内均匀分布的随机浮动点数:

这个函数假设所有维度的上限和下限是相同的。

def randomFloat(low, up):
    return [random.uniform(l, u) for l, \
        u in zip([low] * DIMENSIONS, [up] * DIMENSIONS)]
  1. 接下来,我们定义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)
    
  2. 由于待最小化的对象是 Eggholder 函数,我们直接使用它作为适应度评估函数。由于个体是一个浮动点数的列表,维度(或长度)为 2,我们相应地从个体中提取xy值,然后计算该函数:

    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)
    
  3. 接下来是遗传操作符。由于选择操作符与个体类型无关,并且到目前为止我们在使用锦标赛选择(锦标赛大小为 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)
    
  4. 如我们多次操作所示,我们使用了修改过的 DEAP 简单遗传算法流程,我们在其中加入了精英主义——保留最好的个体(名人堂成员),并将它们传递到下一代,不受遗传操作符的影响:

    population, logbook = elitism.eaSimpleWithElitism(population,
        toolbox,
        cxpb=P_CROSSOVER,
        mutpb=P_MUTATION,
        ngen=MAX_GENERATIONS,
        stats=stats,
        halloffame=hof,
        verbose=True)
    
  5. 我们将从以下遗传算法设置参数开始。由于 Eggholder 函数可能有些难以优化,考虑到低维度的数量,我们使用相对较大的种群大小:

    # Genetic Algorithm constants:
    POPULATION_SIZE = 300
    P_CROSSOVER = 0.9
    P_MUTATION = 0.1
    MAX_GENERATIONS = 300
    HALL_OF_FAME_SIZE = 30
    
  6. 除了之前的常规遗传算法常数外,我们现在需要一个新的常数——拥挤因子(eta),它被交叉和变异操作使用:

    CROWDING_FACTOR = 20.0
    

重要提示

也可以为交叉和变异分别定义不同的拥挤因子。

我们终于准备好运行程序了。使用这些设置得到的结果如下所示:

-- Best Individual = [512.0, 404.23180541839946]
-- Best Fitness = -959.6406627208509

这意味着我们已经找到了全局最小值。

如果我们查看程序生成的统计图(如下所示),可以看出算法一开始就找到了某些局部最小值,随后进行了小幅度的增量改进,直到最终找到了全局最小值:

图 6.2: 优化 Eggholder 函数的第一个程序的统计数据

图 6.2: 优化 Eggholder 函数的第一个程序的统计数据

一个有趣的区域是第 180 代左右——让我们在下一个小节中进一步探讨。

通过增加变异率来提高速度

如果我们放大适应度轴的下部区域,会注意到在第 180 代左右,最佳结果(红线)有了相对较大的改善,同时平均结果(绿线)发生了较大波动:

图 6.3: 第一个程序的统计图放大部分

图 6.3: 第一个程序的统计图放大部分

解释这个现象的一种方式是,或许引入更多噪声能够更快地得到更好的结果。这可能是我们之前讨论过的探索与开发原则的另一种表现——增加探索(在图中表现为噪声)可能帮助我们更快地找到全局最小值。增加探索度的一个简单方法是提高变异的概率。希望使用精英主义——保持最佳结果不变——可以防止我们过度探索,这会导致类似随机搜索的行为。

为了验证这个想法,我们将变异概率从 0.1 提高到 0.5:

P_MUTATION = 0.5

运行修改后的程序后,我们再次找到了全局最小值,但速度要快得多,从输出结果以及接下来展示的统计图中可以明显看出,红线(最佳结果)很快就达到了最优,而平均分数(绿色)比之前更嘈杂,并且离最佳结果更远:

图 6.4: 优化 Eggholder 函数的程序统计数据,变异概率增大

图 6.4: 优化 Eggholder 函数的程序统计数据,变异概率增大

我们在处理下一个基准函数——Himmelblau 函数时会牢记这一点。

优化 Himmelblau 函数

另一个常用的优化算法基准函数是 Himmelblau 函数,如下图所示:

图 6.5: 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 函数的等高线图

图 6.6:Himmelblau 函数的等高线图

来源:commons.wikimedia.org/wiki/File:Himmelblau_contour.svg

图片由 Nicoguaro 提供

在优化多模态函数时,我们通常希望找到所有(或大多数)最小值的位置。然而,让我们先从找到一个最小值开始,这将在下一小节中完成。

使用遗传算法优化 Himmelblau 函数

我们为找到 Himmelblau 函数的单一最小值所创建的基于遗传算法的程序位于02_optimize_himmelblau.py Python 程序中,具体位置见以下链接:

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

该程序与我们用来优化 Eggholder 函数的程序类似,下面列出了几个主要的区别:

  1. 我们为此函数设定了边界为[-5.0, 5.0]:

    BOUND_LOW, BOUND_UP = -5.0, 5.0  # boundaries for all dimensions
    
  2. 现在我们使用 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)
    
  3. 由于我们优化的函数有多个最小值,因此观察运行结束后找到的解的分布可能很有趣。因此,我们添加了一个散点图,显示了四个全局最小值的位置以及最终种群在同一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)
    
  4. 我们还打印了名人堂成员——在运行过程中找到的最佳个体:

    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:第一次运行结束时,种群的散点图,显示了四个函数的最小值

图 6.7:第一次运行结束时种群的散点图,显示了四个函数的最小值

此外,可以明显看出,种群中的许多个体具有我们找到的最小值的xy分量。

这些结果代表了我们通常从遗传算法中期望的结果——识别全局最优解并向其收敛。由于在此情况下我们有多个最小值,因此预计算法会收敛到其中一个。最终会收敛到哪个最小值,主要取决于算法的随机初始化。正如你可能记得的,我们迄今为止在所有程序中都使用了固定的随机种子(值为 42):

RANDOM_SEED = 42
random.seed(RANDOM_SEED)

这样做是为了使结果具有可重复性;然而,在现实中,我们通常会为不同的运行使用不同的随机种子值,方法是注释掉这些行或显式地将常量设置为不同的值。

例如,如果我们将种子值设置为 13,我们将得到解(x=−2.805118, y=3.131312),如下图所示:

图 6.8:第二次运行结束时种群的散点图,显示了四个函数的最小值

图 6.8:第二次运行结束时种群的散点图,显示了四个函数的最小值

如果我们将种子值更改为 17,程序执行将得到解(x=3.584458, y=−1.848126),如下图所示:

图 6.9:第三次运行结束时种群的散点图,显示了四个函数的最小值

图 6.9:第三次运行结束时种群的散点图,显示了四个函数的最小值

然而,如果我们想在一次运行中找到所有的全局最小值呢?正如我们将在下一小节中看到的,遗传算法为我们提供了一种追求这一目标的方法。

使用分区和共享来寻找多个解

第二章《理解遗传算法的关键组件》中,我们提到过遗传算法中的分区共享模拟了自然环境被划分为多个子环境或生态位的方式。这些生态位由不同的物种或子种群填充,利用每个生态位中独特的资源,而在同一生态位中的个体则必须竞争相同的资源。在遗传算法中实现共享机制将鼓励个体探索新的生态位,并可用于寻找多个最优解,每个解都被认为是一个生态位。实现共享的常见方法是将每个个体的原始适应度值与所有其他个体的距离的(某些函数的)合并值相除,从而通过在个体之间共享局部资源来有效地惩罚拥挤的种群。

让我们尝试将这个思路应用于 Himmelblau 函数的优化过程,看看它是否能帮助在一次运行中找到所有四个极小值。这个尝试实现于03_optimize_himmelblau_sharing.py程序中,位于以下链接:

github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_06/03_optimize_himmelblau_sharing.py

该程序基于之前的程序,但我们必须做了一些重要的修改,描述如下:

  1. 首先,实现共享机制通常需要我们优化一个产生正适应度值的函数,并寻找最大值,而不是最小值。这使我们能够通过划分原始适应度值来减少适应度,并实际在相邻个体之间共享资源。由于 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)
    
  2. 为了完成转换,我们将适应度策略重新定义为最大化策略:

    creator.create("FitnessMax", base.Fitness, weights=(1.0,))
    
  3. 为了实现共享,我们首先创建了两个额外的常量:

    DISTANCE_THRESHOLD = 0.1
    SHARING_EXTENT = 5.0
    
  4. 接下来,我们需要实现共享机制。一个便捷的实现位置是在选择遗传算子中。选择算子是检查所有个体适应度值并用于选择下一代父母的位置。这使得我们能够注入一些代码,在选择操作发生之前重新计算这些适应度值,然后在继续之前恢复原始的适应度值,以便进行跟踪。为了实现这一点,我们实现了一个新的**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],
    
  5. 最后,我们添加了一个图表,展示了最佳个体——名人堂成员——在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.10:使用生态位分割法时,在运行结束时最佳解的散点图,以及四个函数的最小值。

同时,展示整个种群分布的图表表明,种群是如何围绕四个解散布的:

图 6.11:使用生态位分割法时,在运行结束时种群的散点图,以及四个函数的最小值

图 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

该约束有效地限制了被认为对该函数有效的xy值。结果如下图所示:

图 6.12:受约束的 Simionescu 函数的轮廓图

图 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 程序中,链接如下:

github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_06/04_optimize_simionescu.py

这个程序与我们在本章中第一次使用的程序非常相似,最初是为 Eggholder 函数创建的,具有以下突出差异:

  1. 设置边界的常量已调整,以匹配 Simionescu 函数的域:

    BOUND_LOW, BOUND_UP = -1.25, 1.25
    
  2. 此外,一个新的常量决定了违反约束时的固定惩罚(或成本):

    PENALTY_VALUE = 10.0
    
  3. 适应度现在由 Simionescu 函数的定义决定:

    def simionescu(individual):
        x = individual[0]
        y = individual[1]
        f = 0.1 * x * y
        return f,  # return a tuple
    toolbox.register("evaluate",simionescu)
    
  4. 有趣的部分从这里开始:我们现在定义一个新的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
    
  5. 然后,我们使用 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 程序,位于以下链接:

github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_06/05_optimize_simionescu_second.py

该程序几乎与之前的程序相同,只做了以下几个小修改:

  1. 我们首先添加了一个常数,用于定义距离阈值,该阈值用于与先前找到的解的距离——新解如果距离任何旧解小于此阈值,则会受到惩罚:

    DISTANCE_THRESHOLD = 0.1
    
  2. 我们接着通过使用一个带有多个子句的条件语句,向**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)的快速概述,并展示遗传算法如何通过选择给定数据集的最相关部分来改善学习模型的结果。

进一步阅读

如需更多信息,请参考以下资源:

第三部分:遗传算法在人工智能中的应用

本部分重点介绍了使用遗传算法增强各种人工智能任务,包括机器学习和自然语言处理。首先展示了这些算法如何通过最优特征选择增强回归和分类任务中的监督学习模型。接着我们探讨了通过超参数调优来提高模型性能,将传统的网格搜索方法与遗传算法方法进行比较。然后我们将焦点转向人工神经网络架构的优化,利用 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

查看以下视频,看看代码是如何运行的:

packt.link/OEBOd

有监督的机器学习

机器学习 这个术语通常指的是一个接收输入并生成输出的计算机程序。我们的目标是训练这个程序,也称为 模型,使其能够对给定的输入生成正确的输出,而无需明确 编程

在此训练过程中,模型通过调整其内部参数来学习输入与输出之间的映射。训练模型的一种常见方法是为它提供一组已知正确输出的输入。对于这些输入,我们告诉模型正确的输出是什么,以便它可以调整或调优自己,最终为每个给定输入产生期望的输出。这种调优是学习过程的核心。

多年来,已经开发出许多类型的机器学习模型。每种模型都有其独特的内部参数,这些参数可以影响输入与输出之间的映射,并且这些参数的值可以进行调整,如下图所示:

图 7.1:机器学习模型的参数调整

图 7.1:机器学习模型的参数调整

例如,如果模型正在实现一个决策树,它可能包含几个IF-THEN语句,可以按如下方式表示:

IF <input value> IS LESS THEN <some threshold value>
    THEN <go to some target branch>

在这种情况下,阈值和目标分支的身份都是可以在学习过程中调整或调优的参数。

为了调整内部参数,每种类型的模型都有一个相应的学习算法,该算法会遍历给定的输入和输出值,并尝试使每个输入的输出与给定的输出相匹配。为了实现这一目标,典型的学习算法会衡量实际输出与期望输出之间的差异(也称为误差,或更广义的损失);然后,算法会通过调整模型的内部参数来最小化这个误差。

监督学习的两种主要类型是分类回归,将在以下小节中进行描述。

分类

在执行分类任务时,模型需要决定某个输入属于哪个类别。每个类别由一个单独的输出(称为标签)表示,而输入被称为特征

图 7.2:机器学习分类模型

图 7.2:机器学习分类模型

例如,在著名的鸢尾花数据集archive.ics.uci.edu/ml/datasets/Iris)中,有四个特征:花瓣长度花瓣宽度萼片长度萼片宽度。这些代表了实际鸢尾花的手动测量值。

在输出方面,有三个标签:鸢尾花 Setosa鸢尾花 Virginica鸢尾花 Versicolor。这些代表了数据集中三种不同类型的鸢尾花。

当输入值代表从某个鸢尾花中获取的测量值时,我们期望正确标签的输出值变高,而其他两个标签的输出值变低:

图 7.3:鸢尾花分类器示意图

图 7.3:鸢尾花分类器示意图

分类任务有许多现实生活中的应用,例如银行贷款和信用卡审批、电子邮件垃圾邮件检测、手写数字识别和人脸识别。本章后面将演示使用动物园数据集进行动物类型分类。

监督学习的第二种主要类型,回归,将在下一个子节中描述。

回归

与分类任务相比,回归任务的模型将输入值映射为单一输出,以提供一个连续值,如下图所示:

图 7.4:机器学习回归模型

图 7.4:机器学习回归模型

给定输入值,模型预计会预测输出的正确值。

回归的实际应用实例包括预测股票价值、葡萄酒质量或房屋市场价格,如下图所示:

图 7.5:房价回归模型

图 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-learnsklearn)库提供了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

该类的主要部分如下:

  1. 类的 init() 方法创建了数据集,具体如下:

    self.X, self.y = datasets.make_friedman1(
        n_samples=self.numSamples,
        n_features=self.numFeatures,
        noise=self.NOISE,
        random_state=self.randomSeed)
    
  2. 然后,使用 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)
    

    将数据分为 训练集验证集,使我们能够在训练集上训练回归模型,其中为训练提供正确的预测,然后在单独的验证集上测试模型,在验证集中不提供正确的预测,而是将其与模型产生的预测进行比较。通过这种方式,我们可以测试模型是否能够泛化,而不是仅仅记住训练数据。

  3. 接下来,我们创建回归模型,并选择了 梯度提升回归器 (GBR) 类型。该模型在训练阶段创建了一个 集成(或聚合)决策树:

    self.regressor = GradientBoostingRegressor(\
        random_state=self.randomSeed)
    

重要提示

在我们的示例中,我们传递了随机种子,以便回归器可以在内部使用它。通过这种方式,我们可以确保得到的结果是可重复的。

  1. 该类的 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)
    
  2. 修改后的训练集——仅包含所选特征——用于训练回归器,而修改后的验证集用于评估回归器的预测:

    self.regressor.fit(currentX_train, self.y_train)
    prediction = self.regressor.predict(currentX_validation)
    return mean_squared_error(self.y_validation, prediction)
    

    这里用于评估回归器的度量叫做 均方误差 (MSE),它计算模型预测值与实际值之间的平均平方差。该度量的 较低 值表示回归器的 更好 性能。

  3. 类的 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 回归问题的误差值图

图 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类中计算的。

以下步骤描述了我们解决方案的主要部分:

  1. 首先,我们需要创建一个Friedman1Test类的实例,并设置所需的参数:

    friedman = friedman.Friedman1Test(NUM_OF_FEATURES, \
        NUM_OF_SAMPLES, RANDOM_SEED)
    
  2. 由于我们的目标是最小化回归模型的 MSE,因此我们定义了一个单一目标,即最小化适应度策略:

    creator.create("FitnessMin", base.Fitness, weights=(-1.0,))
    
  3. 由于解是通过一个 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)
    
  4. 然后,我们指示遗传算法使用Friedman1Test实例的**getMSE()**方法来进行适应度评估:

    def friedmanTestScore(individual):
        return friedman.getMSE(individual),  # return a tuple
    toolbox.register("evaluate", friedmanTestScore)
    
  5. 至于遗传操作符,我们使用锦标赛选择(锦标赛规模为 2),以及专门为二进制列表染色体设计的交叉变异操作符:

    toolbox.register("select", tools.selTournament, tournsize=2)
    toolbox.register("mate", tools.cxTwoPoint)
    toolbox.register("mutate", tools.mutFlipBit, \
        indpb=1.0/len(friedman))
    
  6. 此外,我们继续使用精英主义方法,即名人堂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 文件中,文件路径为:

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

这个类的主要部分如下所示:

  1. 类的 init() 方法从网络加载动物园数据集,同时跳过第一个特征——动物名称,具体如下:

    self.data = read_csv(self.DATASET_URL, header=None, 
        usecols=range(1, 18))
    
  2. 然后,它将数据分离为输入特征(前 16 列)和结果类别(最后一列):

    self.X = self.data.iloc[:, 0:16]
    self.y = self.data.iloc[:, 16]
    
  3. 我们不再像上一节那样仅将数据分割为训练集和测试集,而是使用k 折交叉验证。这意味着数据被分割成k个相等的部分,每次评估时,使用**(k-1)部分作为训练集,剩余部分作为测试集(或验证集**)。在 Python 中,可以使用scikit-learn库的**model_selection.KFold()**方法轻松实现:

    self.kfold = model_selection.KFold(
        n_splits=self.NUM_FOLDS,
        random_state=self.randomSeed)
    
  4. 接下来,我们基于决策树创建一个分类模型。这种类型的分类器在训练阶段创建一个树形结构,将数据集分割成更小的子集,最终生成一个预测:

    self.classifier = DecisionTreeClassifier(
        random_state=self.randomSeed)
    

重要提示

我们传递一个随机种子,以便它可以被分类器内部使用。这样,我们可以确保获得的结果是可重复的。

  1. 该类的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)
    
  2. 这个修改后的数据集——仅包含选定的特征——随后用于执行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次,因此我们使用在这些评估中获得的平均(均值)准确度值。

  3. 该类的主方法创建了一个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。与前一节一样,这里使用的染色体表示是一个整数列表,值为01,表示某个特征是否应被使用或丢弃。

以下步骤突出了程序的主要部分:

  1. 首先,我们需要创建一个Zoo类的实例,并传递我们的随机种子,以确保结果可重复:

    zoo = zoo.Zoo(RANDOM_SEED)
    
  2. 由于我们的目标是最大化分类器模型的准确性,我们定义了一个单一目标,即最大化适应度策略:

    creator.create("FitnessMax", base.Fitness, weights=(1.0,))
    
  3. 就像在前一节中一样,我们使用以下工具箱定义来创建初始种群,每个个体由01整数值组成:

    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)
    
  4. 然后,我们指示遗传算法使用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)
      
  5. 对于遗传算子,我们再次使用锦标赛选择,锦标赛大小为2,并且使用专门针对二进制列表染色体的交叉变异算子:

    toolbox.register("select", tools.selTournament, tournsize=2)
    toolbox.register("mate", tools.cxTwoPoint)
    toolbox.register("mutate", tools.mutFlipBit, indpb=1.0/len(zoo))
    
  6. 再次,我们继续使用精英主义方法,即 HOF 成员——当前最佳个体——总是被直接传递到下一代,而不做改变:

    population, logbook = elitism.eaSimpleWithElitism(population,
        toolbox,
        cxpb=P_CROSSOVER,
        mutpb=P_MUTATION,
        ngen=MAX_GENERATIONS,
        stats=stats,
        halloffame=hof,
        verbose=True)
    
  7. 在运行结束时,我们打印出 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 仓库中找到:

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

查看以下视频,观看代码演示:packt.link/OEBOd

机器学习中的超参数

第七章《使用特征选择提升机器学习模型》中,我们将监督学习描述为调整(或调整)模型内部参数的程序化过程,以便在给定输入时产生期望的输出。为了实现这一目标,每种类型的监督学习模型都配有一个学习算法,在学习(或训练)阶段反复调整其内部参数。

然而,大多数模型还有另一组参数是在学习发生之前设置的。这些参数被称为 超参数,并且它们影响学习的方式。以下图示了这两类参数:

图 8.1:机器学习模型的超参数调优

图 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

  • 脯氨酸

数据集的214列包含前述测量值,而分类结果——即葡萄酒类型本身(123)——则位于第一列。

接下来,让我们看看我们选择的分类器,用于对这个数据集进行分类。

自适应提升分类器

自适应提升算法,简称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 文件中,具体位置为:

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

该类的主要部分如下所示:

  1. 类的 init() 方法初始化葡萄酒数据集、AdaBoost 分类器、k 折交叉验证指标和网格参数:

    self.initWineDataset()
    self.initClassifier()
    self.initKfold()
    self.initGridParams()
    
  2. 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'],
    }
    
  3. getDefaultAccuracy() 方法使用 **'**准确度' 指标的均值评估分类器在其默认超参数值下的准确度:

    cv_results = model_selection.cross_val_score(
        self.classifier,
        self.X,
        self.y,
        cv=self.kfold,
        scoring='accuracy')
    return cv_results.mean()
    
  4. gridTest() 方法在我们之前定义的测试超参数值集合上执行传统网格搜索。最优的参数组合是基于 k 折交叉验证的平均 **'**准确度' 指标来确定的:

    gridSearch = GridSearchCV(
        estimator=self.classifier,
        param_grid=self.gridParams,
        cv=self.kfold,
        scoring='accuracy')
    gridSearch.fit(self.X, self.y)
    
  5. 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)
    
  6. 最后,类的**main()**方法首先评估分类器使用默认超参数值时的性能。然后,它进行常规的全面网格搜索,接着进行基于基因算法的网格搜索,同时记录每次搜索的时间。

运行该类的主方法的结果将在下一小节中描述。

测试分类器的默认性能

运行结果表明,使用默认参数值n_estimators = 50learning_rate = 1.0algorithm = '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 = 50learning_rate ≈ 0.5995algorithm = '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]范围内的浮动数表示。为了转换该浮动值,我们将其四舍五入为最接近的整数——01。然后,我们将0替换为'SAMME',将1替换为'SAMME.R'**。

这些转换将由两个 Python 文件执行,接下来的小节中将描述这两个文件。

评估分类器准确度

我们从一个 Python 类开始,该类封装了分类器的准确度评估,称为HyperparameterTuningGenetic。该类可以在hyperparameter_tuning_genetic_test.py文件中找到,该文件位于

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

该类的主要功能如下所示:

  1. 该类的convertParam()方法接受一个名为params的列表,包含表示超参数的浮动值,并将其转换为实际值(如前一小节所讨论):

    n_estimators = round(params[0])
    learning_rate = params[1]
    algorithm = ['SAMME', 'SAMME.R'][round(params[2])]
    
  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)
    
  3. 然后,它通过我们为葡萄酒数据集创建的 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实现,该程序位于

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

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

  1. 我们首先为表示超参数的每个浮动值设置下界和上界,如前一小节所述——[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]
    
  2. 然后,我们创建了一个HyperparameterTuningGenetic类的实例,这将允许我们测试不同的超参数组合:

    test = HyperparameterTuningGenetic(RANDOM_SEED)
    
  3. 由于我们的目标是最大化分类器的准确率,我们定义了一个单一目标——最大化适应度策略:

    creator.create("FitnessMax", base.Fitness, weights=(1.0,))
    
  4. 现在进入一个特别有趣的部分——由于解的表示是一个浮动值列表,每个值的范围不同,我们使用以下循环遍历所有的下界和上界值对。对于每个超参数,我们创建一个单独的工具箱操作符,用来在适当的范围内生成随机浮动值:

    for i in range(NUM_OF_PARAMS):
        # "hyperparameter_0", "hyperparameter_1", ...
        toolbox.register("hyperparameter_" + str(i),
                          random.uniform,
                          BOUNDS_LOW[i],
                          BOUNDS_HIGH[i])
    
  5. 然后,我们创建了超参数元组,包含我们刚刚为每个超参数创建的具体浮动数字生成器:

    hyperparameters = ()
    for i in range(NUM_OF_PARAMS):
        hyperparameters = hyperparameters + \
            (toolbox.__getattribute__("hyperparameter_" + str(i)),)
    
  6. 现在,我们可以使用这个超参数元组,结合 DEAP 内置的 initCycle() 操作符,创建一个新的 individualCreator 操作符,该操作符通过随机生成的超参数值的组合填充一个个体实例:

    toolbox.register("individualCreator",
                      tools.initCycle,
                      creator.Individual,
                      hyperparameters,
                      n=1)
    
  7. 然后,我们指示遗传算法使用 HyperparameterTuningGenetic 实例的 getAccuracy() 方法进行适应度评估。作为提醒,getAccuracy() 方法(我们在前一小节中描述过)将给定的个体——一个包含三个浮点数的列表——转换回它们所表示的分类器超参数值,用这些值训练分类器,并通过 k 折交叉验证评估其准确性:

    def classificationAccuracy(individual):
        return test.getAccuracy(individual),
    toolbox.register("evaluate", classificationAccuracy)
    
  8. 现在,我们需要定义遗传操作符。对于 selection 操作符,我们使用常见的锦标赛选择,锦标赛大小为 2,我们选择专门为有界浮点列表染色体设计的 crossovermutation 操作符,并为它们提供我们为每个超参数定义的边界:

    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)
    
  9. 此外,我们继续使用精英策略,即 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/)