Python 遗传算法实用指南(三)
原文:
annas-archive.org/md5/37b689acecddb360565f499dd5ebf6d0译者:飞龙
第九章:深度学习网络的架构优化
本章介绍了如何通过优化人工神经网络(ANN)模型的网络架构,利用遗传算法来提高这些模型的性能。我们将首先简要介绍神经网络(NNs)和深度学习(DL)。在介绍了鸢尾花数据集和多层感知器(MLP)分类器后,我们将展示如何通过基于遗传算法的解决方案来进行网络架构优化。随后,我们将扩展此方法,将网络架构优化与模型超参数调优相结合,二者将通过基于遗传算法的解决方案共同完成。
本章将涉及以下主题:
-
理解人工神经网络和深度学习的基本概念
-
通过网络架构优化来提升深度学习分类器的性能
-
通过将网络架构优化与超参数调优相结合,进一步增强深度学习分类器的性能
本章将从人工神经网络的概述开始。如果你是经验丰富的数据科学家,可以跳过介绍部分。
技术要求
本章将使用 Python 3,并配合以下支持库:
-
deap
-
numpy
-
scikit-learn
重要提示
如果你使用我们提供的requirements.txt文件(见第三章),这些库已经包含在你的环境中。
此外,我们将使用 UCI 鸢尾花数据集(archive.ics.uci.edu/ml/datasets/Iris)。
本章将使用的程序可以在本书的 GitHub 仓库中找到,链接如下:
查看以下视频,查看代码实际操作:packt.link/OEBOd
人工神经网络和深度学习
受人脑结构的启发,神经网络是机器学习(ML)中最常用的模型之一。这些网络的基本构建块是节点或神经元,它们基于生物神经元细胞,如下图所示:
图 9.1:生物神经元模型
来源:simple.wikipedia.org/wiki/Neuron#/media/File:Neuron.svg 由 Dhp1080 提供
神经元细胞的树突,在前图左侧围绕细胞体,用作来自多个相似细胞的输入,而从细胞体出来的长轴突则作为输出,可以通过其末端连接到多个其他细胞。
这种结构通过一个人工模型——感知器来模拟,如下所示:
图 9.2:人工神经元模型——感知器
感知器通过将每个输入值与一定的权重相乘来计算输出;结果会累加,然后加上一个偏置值。一个非线性的激活函数随后将结果映射到输出。这种功能模仿了生物神经元的运作,当输入的加权和超过某个阈值时,神经元会“激发”(从其输出端发送一系列脉冲)。
如果我们调整感知器的权重和偏置值,使其将某些输入映射到期望的输出水平,则可以使用感知器模型进行简单的分类和回归任务。然而,通过将多个感知器单元连接成一个叫做 MLP 的结构,可以构建一个功能更强大的模型,下一小节将对其进行描述。
MLP
MLP 通过使用多个节点扩展了感知器的概念,每个节点实现一个感知器。MLP 中的节点按层排列,每一层与下一层相连接。MLP 的基本结构如下图所示:
图 9.3:MLP 的基本结构
MLP 由三个主要部分组成:
-
输入层:接收输入值,并将每个输入值与下一个层中的每个神经元相连接。
-
输出层:传递 MLP 计算的结果。当 MLP 用作分类器时,每个输出表示一个类别。当 MLP 用于回归时,将只有一个输出节点,产生一个连续值。
-
隐藏层:提供该模型的真正力量和复杂性。尽管前面的图示只显示了两个隐藏层,但可以有多个隐藏层,每个隐藏层的大小可以是任意的,这些隐藏层位于输入层和输出层之间。随着隐藏层数量的增加,网络变得更深,能够执行越来越复杂的非线性映射,连接输入和输出。
训练这个模型涉及调整每个节点的权重和偏置值。通常,这通过一类被称为反向传播的算法来实现。反向传播的基本原理是通过将输出误差从输出层向内传播到 MLP 模型的各个层,最小化实际输出与期望输出之间的误差。该过程从定义一个成本(或“损失”)函数开始,通常是预测输出与实际目标值之间差异的度量。通过调整各个节点的权重和偏置,使得那些对误差贡献最大节点的调整最大。通过迭代减少成本函数,算法逐步优化模型参数,提高性能。
多年来,反向传播算法的计算限制使得 MLP 的隐藏层数不超过两层或三层,直到新的发展极大地改变了这一局面。相关内容将在下一节中详细说明。
深度学习和卷积神经网络
近年来,反向传播算法取得了突破,允许在单个网络中使用大量的隐藏层。在这些深度神经网络(DNNs)中,每一层可以解释前一层节点所学到的多个更简单的抽象概念,并产生更高层次的概念。例如,在实现人脸识别任务时,第一层将处理图像的像素并学习检测不同方向的边缘。下一层可能将这些边缘组合成线条、角点等,直到某一层能够检测面部特征,如鼻子和嘴唇,最后,一层将这些特征结合成完整的“面部”概念。
进一步的发展催生了卷积神经网络(CNNs)的概念。这些结构通过对相邻输入与远离的输入进行不同处理,能够减少处理二维信息(如图像)的深度神经网络(DNNs)中的节点数量。因此,这些模型在图像和视频处理任务中尤为成功。除了与多层感知器(MLP)中的隐藏层类似的全连接层外,这些网络还使用池化(下采样)层,池化层将前面层的神经元输出进行汇总,以及卷积层,卷积层通过在输入图像上有效滑动滤波器来检测特定特征,例如各种方向的边缘。
使用scikit-learn库和一个简单的数据集进行训练。然而,所使用的原理仍适用于更复杂的网络和数据集。
在下一节中,我们将探讨如何使用遗传算法优化多层感知器(MLP)的架构。
优化深度学习分类器的架构
在为给定的机器学习任务创建神经网络模型时,一个关键的设计决策是网络架构的配置。对于多层感知机(MLP)而言,输入层和输出层的节点数由问题的特征决定。因此,待决策的部分是隐藏层——有多少层,每一层有多少个节点。可以使用一些经验法则来做出这些决策,但在许多情况下,识别最佳选择可能会变成一个繁琐的试错过程。
处理网络架构参数的一种方法是将它们视为模型的超参数,因为它们需要在训练之前确定,并且因此会影响训练结果。在本节中,我们将应用这种方法,并使用遗传算法来搜索最佳的隐藏层组合,类似于我们在上一章选择最佳超参数值的方式。我们从我们想要解决的任务开始——鸢尾花 分类。
鸢尾花数据集
可能是研究得最透彻的数据集,鸢尾花数据集(archive.ics.uci.edu/ml/datasets/Iris)包含了三种鸢尾花(鸢尾花、弗吉尼亚鸢尾花、和变色鸢尾花)的萼片和花瓣的测量数据,这些数据由生物学家在 1936 年收集。
数据集包含来自三种物种的每种 50 个样本,并由以下四个特征组成:
-
萼片长度 (cm)
-
萼片宽度 (cm)
-
花瓣长度 (cm)
-
花瓣宽度 (cm)
该数据集可以通过scikit-learn库直接获得,并可以通过如下方式初始化:
from sklearn import datasets
data = datasets.load_iris()
X = data['data']
y = data['target']
在我们的实验中,我们将使用多层感知机(MLP)分类器与此数据集结合,并利用遗传算法的力量来寻找最佳的网络架构——隐藏层的数量和每层节点的数量——以获得最佳的分类准确率。
由于我们使用遗传算法的方法,首先需要做的是找到一种方法,通过染色体表示这种架构,如下一个小节所述。
表示隐藏层配置
由于多层感知器(MLP)的结构由隐藏层配置决定,让我们来探讨如何在我们的解决方案中表示这一配置。sklearn MLP 的隐藏层配置(scikit-learn.org/stable/modules/neural_networks_supervised.html)通过 hidden_layer_sizes 元组传递,这个元组作为参数传递给模型的构造函数。默认情况下,这个元组的值为 (100,),意味着只有一个包含 100 个节点的隐藏层。如果我们想要将 MLP 配置为三个每个包含 20 个节点的隐藏层,则该参数的值应为 (20, 20, 20)。在我们实现基于遗传算法的优化器来调整隐藏层配置之前,我们需要定义一个可以转换为这种模式的染色体。
为了实现这一目标,我们需要设计一种染色体,既能够表示层的数量,又能表示每层的节点数。一种可行的方案是使用一个可变长度的染色体,该染色体可以直接转换为用作模型 hidden_layer_sizes 参数的可变长度元组;然而,这种方法需要定制且可能繁琐的遗传操作符。为了能够使用我们的标准遗传操作符,我们将使用一个固定长度的表示法。采用这种方法时,最大层数是预先确定的,所有层始终被表示,但不一定会在解决方案中得到体现。例如,如果我们决定将网络限制为四个隐藏层,染色体将如下所示:
[n 1, n 2, n 3, n 4]
这里,n i 表示第 i 层的节点数。然而,为了控制网络中实际的隐藏层数量,这些值中的一些可能是零或负数。这样的值意味着不会再添加更多的层到网络中。以下示例展示了这种方法:
-
染色体 [10, 20, -5, 15] 被转换为元组 (10, 20),因为 -5 终止了层的计数
-
染色体 [10, 0, -5, 15] 被转换为元组 (10, ),因为 0 终止了层的计数
-
染色体 [10, 20, 5, -15] 被转换为元组 (10, 20, 5),因为 -15 终止了层的计数
-
染色体 [10, 20, 5, 15] 被转换为元组 (10, 20, 5, 15)
为了保证至少有一个隐藏层,我们可以确保第一个参数始终大于零。其他参数可以围绕零分布变化,以便我们能够控制它们作为终止参数的可能性。
此外,尽管该染色体由整数构成,我们选择改用浮动数字,就像我们在上一章中对各种类型的变量所做的那样。使用浮动数字的列表非常方便,因为它允许我们使用现有的遗传算子,同时能够轻松扩展染色体,以便包含其他不同类型的参数,稍后我们会这样做。浮动数字可以通过round()函数转换回整数。以下是这种通用方法的几个示例:
-
染色体[9.35, 10.71, -2.51, 17.99]被转换为元组(9, 11)
-
染色体[9.35, 10.71, 2.51, -17.99]被转换为元组(9, 11, 3)
为了评估给定的表示架构的染色体,我们需要将其转换回层的元组,创建一个实现这些层的 MLP 分类器,训练它并进行评估。我们将在下一小节中学习如何做到这一点。
评估分类器的准确性
让我们从一个 Python 类开始,该类封装了 Iris 数据集的 MLP 分类器准确性评估。该类被称为MlpLayersTest,可以在以下链接的mlp_layers_test.py文件中找到:
该类的主要功能如下所示:
-
该类的convertParam()方法接收一个名为params的列表。实际上,这就是我们在上一小节中描述的染色体,它包含表示最多四个隐藏层的浮动值。该方法将这些浮动值的列表转换为hidden_layer_sizes元组:
if round(params[1]) <= 0: hiddenLayerSizes = round(params[0]), elif round(params[2]) <= 0: hiddenLayerSizes = (round(params[0]), round(params[1])) elif round(params[3]) <= 0: hiddenLayerSizes = (round(params[0]), round(params[1]), round(params[2])) else: hiddenLayerSizes = (round(params[0]), round(params[1]), round(params[2]), round(params[3])) -
getAccuracy()方法接受表示隐藏层配置的params列表,使用convertParam()方法将其转换为hidden_layer_sizes元组,并使用该元组初始化 MLP 分类器:
hiddenLayerSizes = self.convertParams(params) self.classifier = MLPClassifier( hidden_layer_sizes=hiddenLayerSizes)然后,它使用与我们在 第八章中为葡萄酒数据集创建的相同k 折交叉验证计算来找到分类器的准确性,机器学习模型的超参数调优:
cv_results = model_selection.cross_val_score(self.classifier, self.X, self.y, cv=self.kfold, scoring='accuracy') return cv_results.mean()
MlpLayersTest类被基于遗传算法的优化器所使用。我们将在下一节中解释这一部分。
使用遗传算法优化 MLP 架构
现在我们有了一种表示用于分类 Iris 花卉数据集的 MLP 架构配置的方法,并且有了一种确定每个配置的 MLP 准确性的方法,我们可以继续并创建一个基于遗传算法的优化器,来搜索最佳的配置——隐藏层的数量(在我们的例子中最多为 4 层)以及每层的节点数量——以获得最佳的准确性。这个解决方案通过位于以下链接的01_optimize_mlp_layers.py Python 程序实现:
以下步骤描述了该程序的主要部分:
-
我们首先为表示隐藏层的每个浮动值设置上下边界。第一个隐藏层的范围为[5, 15],而其余的层从逐渐增大的负值开始,这增加了它们终止层数的几率:
# [hidden_layer_layer_1_size, hidden_layer_2_size # hidden_layer_3_size, hidden_layer_4_size] BOUNDS_LOW = [ 5, -5, -10, -20] BOUNDS_HIGH = [15, 10, 10, 10] -
然后,我们创建一个MlpLayersTest类的实例,这将允许我们测试不同的隐藏层架构组合:
test = mlp_layers_test.MlpLayersTest(RANDOM_SEED) -
由于我们的目标是最大化分类器的准确性,我们定义了一个单一的目标,即最大化适应度策略:
creator.create("FitnessMax", base.Fitness, weights=(1.0,)) -
现在,我们采用与上一章相同的方法——由于解决方案由一个浮动数值列表表示,每个数值的范围不同,我们使用以下循环遍历所有的下限和上限值对,并且对于每个范围,我们创建一个单独的toolbox操作符layer_size_attribute,该操作符将用于在适当的范围内生成随机浮动数值:
for i in range(NUM_OF_PARAMS): toolbox.register("layer_size_attribute_" + str(i), random.uniform, BOUNDS_LOW[i], BOUNDS_HIGH[i]) -
然后,我们创建一个layer_size_attributes元组,其中包含我们为每个隐藏层刚刚创建的单独浮动数值生成器:
layer_size_attributes = () for i in range(NUM_OF_PARAMS): layer_size_attributes = layer_size_attributes + \ (toolbox.__getattribute__("layer_size_attribute_" + \ str(i)),) -
现在,我们可以将这个layer_size_attributes元组与 DEAP 内置的initCycle()操作符结合使用,来创建一个新的individualCreator操作符,该操作符将通过随机生成的隐藏层大小值的组合填充一个个体实例:
toolbox.register("individualCreator", tools.initCycle, creator.Individual, layer_size_attributes, n=1) -
然后,我们指示遗传算法使用MlpLayersTest实例的**getAccuracy()**方法进行适应度评估。提醒一下,**getAccuracy()**方法(我们在上一小节中描述过)将给定个体——一个包含四个浮动数值的列表——转换为一个隐藏层大小的元组。这些元组将用于配置 MLP 分类器。然后,我们训练分类器并使用 k 折交叉验证评估其准确性:
def classificationAccuracy(individual): return test.getAccuracy(individual), toolbox.register("evaluate", classificationAccuracy) -
至于遗传操作符,我们重复了上一章的配置。对于选择操作符,我们使用常规的锦标赛选择,锦标赛大小为 2,选择适用于有界浮动列表染色体的交叉和变异操作符,并为每个隐藏层提供我们定义的边界:
toolbox.register("select", tools.selTournament, tournsize=2) toolbox.register("mate", tools.cxSimulatedBinaryBounded, low=BOUNDS_LOW, up=BOUNDS_HIGH, eta=CROWDING_FACTOR) toolbox.register("mutate", tools.mutPolynomialBounded, low=BOUNDS_LOW, up=BOUNDS_HIGH, eta=CROWDING_FACTOR, indpb=1.0 / NUM_OF_PARAMS) -
此外,我们继续使用精英方法,其中名人堂(HOF)成员——当前最佳个体——总是被不加修改地传递到下一代:
population, logbook = elitism.eaSimpleWithElitism(population, toolbox, cxpb=P_CROSSOVER, mutpb=P_MUTATION, ngen=MAX_GENERATIONS, stats=stats, halloffame=hof, verbose=True)
当我们用 20 个个体运行算法 10 代时,得到的结果如下:
gen nevals max avg
0 20 0.666667 0.416333
1 17 0.693333 0.487
2 15 0.76 0.537333
3 14 0.76 0.550667
4 17 0.76 0.568333
5 17 0.76 0.653667
6 14 0.76 0.589333
7 15 0.76 0.618
8 16 0.866667 0.616667
9 16 0.866667 0.666333
10 16 0.866667 0.722667
- Best solution is: 'hidden_layer_sizes'=(15, 5, 8) , accuracy = 0.8666666666666666
之前的结果表明,在我们定义的范围内,找到的最佳组合是三个隐藏层,大小分别为 15、5 和 8。我们使用这些值所获得的分类准确率大约为 86.7%。
这个准确率似乎是对于当前问题的合理结果。然而,我们还有更多的工作可以做,以进一步提高其准确性。
将架构优化与超参数调优结合
在优化网络架构配置——即隐藏层参数——时,我们一直使用 MLP 分类器的默认(超)参数。然而,正如我们在上一章中所看到的,调优各种超参数有可能提高分类器的性能。我们能否将超参数调优纳入我们的优化中?如你所猜测的,答案是肯定的。但在此之前,让我们先来看一下我们希望优化的超参数。
scikit-learn实现的 MLP 分类器包含许多可调的超参数。为了展示,我们将集中在以下超参数上:
| 名称 | 类型 | 描述 | 默认值 |
|---|---|---|---|
| 激活函数 | 枚举类型 | 隐藏层的激活函数:{'identity', 'logistic', 'tanh', 'relu'} | 'relu' |
| 求解器 | 枚举类型 | 权重优化的求解器:{'lbfgs', 'sgd', 'adam'} | 'adam' |
| alpha | 浮动型 | L2 正则化项的强度 | 0.0001 |
| 学习率 | 枚举类型 | 权重更新的学习率计划:{‘constant’, ‘invscaling’, ‘adaptive’} | 'constant' |
表 9.1:MLP 超参数
正如我们在上一章所看到的,基于浮动点的染色体表示使我们能够将各种类型的超参数结合到基于遗传算法的优化过程中。由于我们已经使用基于浮动点的染色体来表示隐藏层的配置,我们现在可以通过相应地扩展染色体,将其他超参数纳入优化过程中。让我们来看看我们如何做到这一点。
解的表示
对于现有的四个浮动值,表示我们的网络架构配置——
[n 1, n 2, n 3, n 4]——我们可以添加以下四个超参数:
-
activation可以有四个值之一:'tanh'、'relu'、'logistic'或'identity'。可以通过将其表示为[0, 3.99]范围内的浮点数来实现。为了将浮点值转换为上述值之一,我们需要对其应用floor()函数,这将得到 0、1、2 或 3。然后,我们将 0 替换为'tanh',将 1 替换为**'relu',将 2 替换为'logistic',将 3 替换为'identity'**。
-
solver可以有三个值之一:'sgd'、'adam'或'lbfgs'。与激活参数一样,它可以使用[0, 2.99]范围内的浮点数表示。
-
alpha已经是一个浮点数,因此无需转换。它将被限制在[0.0001, 2.0]的范围内。
-
learning_rate可以有三个值之一:'constant'、'invscaling'或'adaptive'。同样,我们可以使用[0, 2.99]范围内的浮点数来表示其值。
评估分类器的准确性
用于评估给定隐藏层和超参数组合的 MLP 分类器准确性的类叫做MlpHyperparametersTest,并包含在mlp_hyperparameters_test.py文件中,该文件位于以下链接:
这个类基于我们用于优化隐藏层配置的类MlpLayersTest,但做了一些修改。我们来看看这些修改:
-
convertParam()方法现在处理一个params列表,其中前四个条目(params[0]到params[3])代表隐藏层的大小,和之前一样,但另外,**params[4]到params[7]**代表我们为评估添加的四个超参数。因此,方法已通过以下代码行进行了扩展,允许它将其余给定的参数(params[4]到params[7])转换为相应的值,然后可以传递给 MLP 分类器:
activation = ['tanh', 'relu', 'logistic', 'identity'][floor(params[4])] solver = ['sgd', 'adam', 'lbfgs'][floor(params[5])] alpha = params[6] learning_rate = ['constant', 'invscaling', 'adaptive'][floor(params[7])] -
同样,getAccuracy()方法现在处理扩展后的params列表。它使用所有这些参数的转换值来配置 MLP 分类器,而不是仅仅配置隐藏层的设置:
hiddenLayerSizes, activation, solver, alpha, learning_rate = \ self.convertParams(params) self.classifier = MLPClassifier( random_state=self.randomSeed, hidden_layer_sizes=hiddenLayerSizes, activation=activation, solver=solver, alpha=alpha, learning_rate=learning_rate)
这个MlpHyperparametersTest类被基于遗传算法的优化器使用。我们将在下一节中讨论这个内容。
使用遗传算法优化 MLP 的组合配置
基于遗传算法的最佳隐藏层和超参数组合搜索由02_ptimize_mlp_hyperparameters.py Python 程序实现,该程序位于以下链接:
由于所有参数都使用统一的浮点数表示,这个程序与我们在前一节中用来优化网络架构的程序几乎相同。主要的区别在于BOUNDS_LOW和BOUNDS_HIGH列表的定义,它们包含了参数的范围。除了之前定义的四个范围(每个隐藏层一个),我们现在添加了另外四个范围,代表我们在本节中讨论的额外超参数:
# 'hidden_layer_sizes': first four values
# 'activation' : 0..3.99
# 'solver' : 0..2.99
# 'alpha' : 0.0001..2.0
# 'learning_rate' : 0..2.99
BOUNDS_LOW = [ 5, -5, -10, -20, 0, 0, 0.0001, 0]
BOUNDS_HIGH = [15, 10, 10, 10, 3.999, 2.999, 2.0, 2.999]
就这么简单——程序能够处理新增的参数而无需进一步修改。
运行此程序将产生以下结果:
gen nevals max avg
0 20 0.94 0.605667
1 15 0.94 0.667
2 16 0.94 0.848667
3 17 0.94 0.935
4 17 0.94 0.908667
5 15 0.94 0.936
6 15 0.94 0.889667
7 16 0.94 0.938333
8 17 0.946667 0.938333
9 13 0.946667 0.938667
10 15 0.946667 0.940667
- Best solution is:
'hidden_layer_sizes'=(7, 4, 6)
'activation'='tanh'
'solver'='lbfgs'
'alpha'=1.2786182334834102
'learning_rate'='constant'
=> accuracy = 0.9466666666666667
重要提示
请注意,由于操作系统之间的差异,当你在自己的系统上运行该程序时,可能会得到与此处展示的结果略有不同的输出。
前述结果表明,在我们定义的范围内,找到的最佳组合如下:
-
三个隐藏层,分别为 7、4 和 6 个节点。
-
'tanh'类型的激活函数参数——而不是默认的**'relu'**
-
'lbfgs'类型的求解器参数——而不是默认的**'adam'**
-
alpha值约为1.279——比默认值 0.0001 大得多
-
'constant'类型的learning_rate参数——与默认值相同
这种联合优化最终达到了约 94.7%的分类准确率——比之前的结果有了显著提升,而且使用的节点比之前更少。
总结
在本章中,你了解了人工神经网络(ANN)和深度学习(DL)的基本概念。熟悉了 Iris 数据集和 MLP 分类器后,我们介绍了网络架构优化的概念。接下来,我们演示了基于遗传算法的 MLP 分类器网络架构优化。最后,我们能够将网络架构优化与模型超参数调优结合起来,使用相同的遗传算法方法,从而进一步提升分类器的性能。
到目前为止,我们集中讨论了监督学习(SL)。在下一章中,我们将探讨将遗传算法应用于强化学习(RL),这是一个令人兴奋且快速发展的机器学习分支。
深入阅读
如需了解本章内容的更多信息,请参考以下资源:
-
Python 深度学习——第二版, Gianmario Spacagna, Daniel Slater, 等, 2019 年 1 月 16 日
-
使用 Python 的神经网络项目, James Loy, 2019 年 2 月 28 日
-
scikit-learn MLP 分类器:
-
scikit-learn.org/stable/modules/generated/sklearn.neural_network.MLPClassifier.html -
UCI 机器学习 数据集库:
archive.ics.uci.edu/
第十章:使用遗传算法进行强化学习
在本章中,我们将展示如何将遗传算法应用于强化学习——这一快速发展的机器学习分支,能够解决复杂的任务。我们将通过解决来自Gymnasium(前身为OpenAI Gym)工具包的两个基准环境来实现这一目标。我们将首先概述强化学习,随后简要介绍Gymnasium,这是一个可用于比较和开发强化学习算法的工具包,并描述其基于 Python 的接口。接下来,我们将探索两个 Gymnasium 环境,MountainCar和CartPole,并开发基于遗传算法的程序来解决它们所面临的挑战。
在本章中,我们将涵盖以下主题:
-
理解强化学习的基本概念
-
熟悉Gymnasium项目及其共享接口
-
使用遗传算法解决Gymnasium的MountainCar环境
-
使用遗传算法结合神经网络解决Gymnasium的CartPole环境
我们将通过概述强化学习的基本概念来开始本章。如果你是经验丰富的数据科学家,可以跳过这一介绍部分。
技术要求
在本章中,我们将使用 Python 3 及以下支持库:
-
deap
-
numpy
-
scikit-learn
-
gymnasium – 在本章中介绍
-
pygame – 在本章中介绍
重要提示
如果你使用我们提供的requirements.txt文件(参见第三章),这些库已经包含在你的环境中了。
本章将使用的Gymnasium环境是MountainCar-v0(gymnasium.farama.org/environments/classic_control/mountain_car/)和CartPole-v1(gymnasium.farama.org/environments/classic_control/cart_pole/)。
本章中使用的程序可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/tree/main/chapter_10。
查看以下视频,看看代码如何运行:packt.link/OEBOd。
强化学习
在前几章中,我们讨论了与机器学习相关的多个主题,重点介绍了监督学习任务。尽管监督学习非常重要,并且有许多现实生活中的应用,但目前看来,强化学习是机器学习中最令人兴奋和最有前景的分支。人们对这一领域的兴奋之情源自于强化学习能够处理的复杂且类似于日常生活的任务。2016 年 3 月,基于强化学习的AlphaGo系统成功战胜了被认为是过去十年最强围棋选手的选手,并且这一比赛得到了广泛的媒体报道。
虽然监督学习需要标注数据进行训练——换句话说,需要输入与匹配输出的对——但强化学习并不会立即给出对错反馈;相反,它提供了一个寻求长期、累积奖励的环境。这意味着,有时算法需要暂时后退一步,才能最终实现长期目标,正如我们将在本章的第一个例子中展示的那样。
强化学习任务的两个主要组件是环境和智能体,如下面的图示所示:
图 10.1: 强化学习表示为智能体与环境之间的互动
智能体代表了一种与环境互动的算法,它通过最大化累积奖励来尝试解决给定的问题。
智能体与环境之间发生的交换可以表示为一系列的步骤。在每一步中,环境会向智能体呈现一个特定的状态(s),也称为观察。智能体则执行一个动作(a)。环境会回应一个新的状态(s’),以及一个中间奖励值(R)。这一交换会一直重复,直到满足某个停止条件。智能体的目标是最大化沿途收集的奖励值的总和。
尽管这一表述非常简单,但它可以用来描述极其复杂的任务和情境,这也使得强化学习适用于广泛的应用场景,如博弈论、医疗保健、控制系统、供应链自动化和运筹学。
本章将再次展示遗传算法的多功能性,因为我们将利用它们来辅助强化学习任务。
遗传算法与强化学习
为了执行强化学习任务,已经开发了多种专用算法——如 Q-Learning、SARSA 和 DQN 等。然而,由于强化学习任务涉及最大化长期奖励,我们可以将它们视为优化问题。正如本书中所展示的,遗传算法可以用于解决各种类型的优化问题。因此,遗传算法也可以用于强化学习,并且有几种不同的方式——本章将演示其中的两种。在第一种情况下,我们基于遗传算法的解决方案将直接提供智能体的最佳动作序列。在第二种情况下,它将为提供这些动作的神经控制器提供最佳参数。
在我们开始将遗传算法应用于强化学习任务之前,让我们先了解将用于执行这些任务的工具包——Gymnasium。
Gymnasium
Gymnasium (gymnasium.farama.org/) —— 这是 OpenAI Gym 的一个分支和官方继任者 —— 是一个开源库,旨在提供对标准化强化学习任务集合的访问。它提供了一个工具包,用于比较和开发强化学习算法。
Gymnasium 是由一系列环境组成的集合,这些环境都呈现一个共同的接口,称为 env。这个接口将各种环境与智能体解耦,智能体可以以任何我们喜欢的方式实现——智能体唯一的要求是能够通过 env 接口与环境进行交互。这个内容将在下一小节中进行描述。
基本包 gymnasium 提供对多个环境的访问,可以通过以下方式进行安装:
pip install gymnasium
为了使我们能够渲染和动画化测试环境,还需要安装 PyGame 库。可以使用以下命令进行安装:
pip install pygame
还有一些其他的包可用,例如“Atari”、“Box2D”和“MuJoCo”,它们提供对多个多样化环境的访问。这些包有些具有系统依赖性,可能只适用于某些操作系统。更多信息请访问 github.com/Farama-Foundation/Gymnasium#installation。
下一小节将描述如何与 env 接口进行交互。
env 接口
要创建一个环境,我们需要使用 make() 方法并提供所需环境的名称,如下所示:
import gymnasium as gym
env = gym.reset() method, as shown in the following code snippet:
observation, info = env.observation 对象,描述环境的初始状态,以及一个字典 info,可能包含补充 observation 的辅助信息。observation 的内容依赖于环境。
与我们在上一节中描述的强化学习周期一致,与环境的持续互动包括发送一个 动作,然后接收一个 中间奖励 和一个新的 状态。这一过程通过 step() 方法实现,如下所示:
observation, reward, terminated, truncated, info = \
env.observation object, which describes the new state and the float reward value that represent the interim reward, this method returns the following values:
* **terminated**: A Boolean that turns **true** when the current run (also called *episode*) reaches the terminal state – for example, the agent lost a life, or successfully completed a task.
* **truncated**: A Boolean that can be used to end the episode prematurely before a terminal state is reached – for example, due to a time limit, or if the agent went out of bounds.
* **info**: A dictionary containing optional, additional information that may be useful for debugging. However, it should not be used by the agent for learning.
At any point in time, the environment can be rendered for visual presentation, as follows:
env.render_mode 可以在创建环境时进行设置。例如,设置为 "human" 会使环境在当前显示器或终端中持续渲染,而默认值 None 则不会进行渲染。
最后,可以关闭环境以调用任何必要的清理操作,如下所示:
env.close()
如果没有调用此方法,环境将在下一次 Python 执行 垃圾回收 进程(即识别并释放程序不再使用的内存)时自动关闭,或者当程序退出时关闭。
注意
有关 env 接口的详细信息,请参见 gymnasium.farama.org/api/env/。
与环境的完整交互周期将在下一节中演示,在那里我们将遇到第一个 Gymnasium 挑战——MountainCar 环境。
解决 MountainCar 环境
MountainCar-v0 环境模拟了一辆位于两座山丘之间的单维轨道上的汽车。模拟开始时,汽车被放置在两座山丘之间,如下图所示:
图 10.2:MountainCar 模拟——起点
目标是让汽车爬上更高的山丘——右侧的山丘——并最终触碰到旗帜:
图 10.3:MountainCar 模拟——汽车爬上右侧山丘
这个模拟设置的情景是汽车的引擎太弱,无法直接爬上更高的山丘。达到目标的唯一方法是让汽车前后行驶,直到积累足够的动能以供攀爬。爬上左侧山丘有助于实现这一目标,因为到达左侧山顶会使汽车反弹到右侧,以下截图展示了这一过程:
图 10.4:MountainCar 模拟——汽车从左侧山丘反弹
这个模拟是一个很好的例子,表明中间的损失(向左移动)可以帮助实现最终目标(完全向右移动)。
这个模拟中的预期 动作 值是一个整数,取以下三个值之一:
-
0: 向左推动
-
1: 不推动
-
2: 向右推动
observation 对象包含两个浮动值,描述了汽车的位置和速度,如下所示:
[-1.0260268, -0.03201975]
最后,reward值在每个时间步为-1,直到达到目标(位于位置 0.5)。如果在 200 步之前没有达到目标,模拟将会停止。
该环境的目标是尽可能快速地到达位于右侧山丘顶部的旗帜,因此,智能体在每个时间步上都会被扣除-1 的奖励。
关于MountainCar-v0环境的更多信息可以在这里找到:
gymnasium.farama.org/environments/classic_control/mountain_car/。
在我们的实现中,我们将尝试使用最少的步数来撞击旗帜,因为我们会从固定的起始位置应用一系列预选的动作。为了找到一个能够让小车爬上高山并撞击旗帜的动作序列,我们将设计一个基于遗传算法的解决方案。像往常一样,我们将首先定义这个挑战的候选解应如何表现。
解的表示
由于MountainCar是通过一系列动作来控制的,每个动作的值为 0(向左推动)、1(不推动)或 2(向右推动),并且在单个回合中最多可以有 200 个动作,因此表示候选解的一种显而易见的方法是使用长度为 200 的列表,列表中的值为 0、1 或 2。一个示例如下:
[0, 1, 2, 0, 0, 1, 2, 2, 1, ... , 0, 2, 1, 1]
列表中的值将用作控制小车的动作,并且希望能够把它驱动到旗帜。如果小车在少于 200 步的时间内到达了旗帜,列表中的最后几项将不会被使用。
接下来,我们需要确定如何评估这种形式的给定解。
评估解的质量
在评估给定解时,或者在比较两个解时,很明显,单独的奖励值可能无法提供足够的信息。根据当前奖励的定义,如果我们没有撞到旗帜,它的值将始终为-200。当我们比较两个没有撞到旗帜的候选解时,我们仍然希望知道哪一个更接近旗帜,并将其视为更好的解。因此,除了奖励值外,我们还将使用小车的最终位置来确定解的得分:
-
如果小车没有撞到旗帜,得分将是与旗帜的距离。因此,我们将寻找一个能够最小化得分的解。
-
如果小车撞到旗帜,基础得分将为零,从此基础上根据剩余未使用的步骤数扣除一个额外的值,使得得分为负。由于我们寻求最低的得分,这种安排将鼓励解通过尽可能少的动作撞击旗帜。
该评分评估过程由MountainCar类实现,下面的子章节中将对其进行详细探讨。
Python 问题表示
为了封装 MountainCar 挑战,我们创建了一个名为MountainCar的 Python 类。该类包含在mountain_car.py文件中,文件位于github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_10/mountain_car.py。
该类通过一个随机种子初始化,并提供以下方法:
-
getScore(actions):计算给定解决方案的得分,解决方案由动作列表表示。得分是通过启动一个MountainCar环境的回合并用提供的动作运行它来计算的,如果在少于 200 步的情况下击中目标,得分可能为负值。得分越低越好。
-
saveActions(actions):使用pickle(Python 的对象序列化和反序列化模块)将动作列表保存到文件。
-
replaySavedActions():反序列化最后保存的动作列表,并使用replay方法重放它。
-
replay(actions):使用“human”render_mode渲染环境,并重放给定的动作列表,展示给定的解决方案。
类的主要方法可以在找到解决方案、序列化并使用saveActions()方法保存后使用。主方法将初始化类并调用replaySavedActions()以渲染和动画展示最后保存的解决方案。
我们通常使用主方法来展示由遗传算法程序找到的最佳解决方案的动画。接下来的小节将详细探讨这一点。
遗传算法解决方案
为了使用遗传算法方法解决MountainCar挑战,我们创建了一个 Python 程序01_solve_mountain_car.py,该程序位于github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_10/01_solve_mountain_car.py。
由于我们为此问题选择的解决方案表示方法是包含 0、1 或 2 整数值的列表,因此这个程序与我们在第四章《组合优化》中用来解决 0-1 背包问题的程序相似,在那里解决方案是以包含 0 和 1 的列表表示的。
以下步骤描述了如何创建该程序的主要部分:
-
我们通过创建MountainCar类的实例开始,这将允许我们为MountainCar挑战打分,评估各种解决方案:
car = mountain_car.MountainCar(RANDOM_SEED)- 由于我们的目标是最小化得分——换句话说,使用最少的步数击中旗帜,或者尽可能接近旗帜——我们定义了一个单一目标,最小化适应度策略:
creator.create("FitnessMin", base.Fitness, weights=(-1.0,))- 现在,我们需要创建一个工具箱操作符,用来生成三个允许的动作值之一——0、1 或 2:
toolbox.register("zeroOneOrTwo", random.randint, 0, 2)- 接下来是一个操作符,它用这些值填充个体实例:
toolbox.register("individualCreator", tools.initRepeat, creator.Individual, toolbox.zeroOneOrTwo, len(car))- 然后,我们指示遗传算法使用
getScore()方法,该方法在前一小节中描述过,启动一个MountainCar环境的回合,并使用给定的个体——一组动作——作为环境的输入,直到回合结束。然后,根据汽车的最终位置评估分数——分数越低越好。如果汽车撞到旗帜,分数甚至可能是负数,具体取决于剩余未使用步骤的数量:
def carScore(individual): return car.getScore(individual), toolbox.register("evaluate", carScore)- 至于遗传操作符,我们从通常的锦标赛选择开始,锦标赛规模为 2。由于我们的解表示是由 0、1 或 2 组成的整数值列表,我们可以像解表示为 0 和 1 值列表时那样,使用二点交叉操作符。
对于变异,与通常用于二进制情况的FlipBit操作符不同,我们需要使用UniformInt操作符,它适用于一系列整数值,并将其配置为 0 到 2 的范围:
toolbox.register("select", tools.selTournament, tournsize=2) toolbox.register("mate", tools.cxTwoPoint) toolbox.register("mutate", tools.mutUniformInt, low=0, up=2, indpb=1.0/len(car))- 此外,我们继续使用精英方法,即名人堂(HOF)成员——当前的最佳个体——总是会原封不动地传递到下一代:
population, logbook = elitism.eaSimpleWithElitism(population, toolbox, cxpb=P_CROSSOVER, mutpb=P_MUTATION, ngen=MAX_GENERATIONS, stats=stats, halloffame=hof, verbose=True)- 运行结束后,我们打印出最佳解并将其保存,以便稍后使用我们在MountainCar类中构建的重放功能进行动画演示:
best = hof.items[0] print("Best Solution = ", best) print("Best Fitness = ", best.fitness.values[0]) car.saveActions(best)运行该算法 80 代,种群规模为 100 时,我们得到以下结果:
gen nevals min avg 0 100 0.708709 1.03242 1 78 0.708709 0.975704 ... 47 71 0.000170529 0.0300455 48 74 4.87566e-05 0.0207197 49 75 -0.005 0.0150622 50 77 -0.005 0.0121327 ... 56 77 -0.02 -0.00321379 57 74 -0.025 -0.00564184 ... 79 76 -0.035 -0.0342 80 76 -0.035 -0.03425 Best Solution = [1, 0, 2, 1, 1, 2, 0, 2, 2, 2, 0, ... , 2, 0, 1, 1, 1, 1, 1, 0] Best Fitness = -0.035
从前面的输出中,我们可以看到,在大约 50 代之后,最佳解开始撞击旗帜,产生零分或更低的分数值。从此以后,最佳解在更少的步骤中撞击旗帜,导致越来越低的分数值。
如我们之前提到的,最佳解在运行结束时被保存,现在我们可以通过运行mountain_car程序来重放它。这个重放展示了我们的解如何驱动汽车在两个山峰之间来回摆动,每次都爬得更高,直到汽车能够爬上左侧的低山。然后,它会反弹回来,这意味着我们已经积累了足够的动能,可以继续爬上右侧的更高山峰,最终撞击旗帜,以下面的截图所示:
图 10.5:MountainCar 仿真——汽车到达目标
尽管解决它非常有趣,但这个环境的设置并不要求我们与其进行动态交互。我们能够通过一系列由我们算法根据小车的初始位置组成的动作来爬上山坡。与此不同,我们即将面对的下一个环境——名为CartPole——要求我们根据最新的观察结果,在任何时间步骤动态计算我们的动作。继续阅读,了解如何实现这一点。
解决 CartPole 环境
CartPole-v1环境模拟了一个杆平衡的过程,杆底部铰接在一个小车上,小车沿着轨道左右移动。保持杆竖直通过施加 1 个单位的力到小车上——每次向右或向左。
在这个环境中,杆像一个摆锤一样开始竖立,并以一个小的随机角度出现,如下图所示:
图 10.6:CartPole 仿真—起始点
我们的目标是尽可能长时间地保持摆锤不倾倒到任一侧——即,最多 500 个时间步骤。每当杆保持竖直时,我们将获得+1 的奖励,因此最大总奖励为 500。若在运行过程中发生以下任何情况,回合将提前结束:
-
杆的角度偏离垂直位置超过 15 度
-
小车距离中心的距离超过 2.4 单位
因此,在这些情况下,最终的奖励将小于 500。
在这个仿真中,期望的action值是以下两个值之一的整数:
-
0:将小车推向左侧
-
1:将小车推向右侧
observation对象包含四个浮动值,保存以下信息:
-
小车位置,在-2.4 到 2.4 之间
-
小车速度,在-Inf 到 Inf 之间
-
杆角度,在-0.418 弧度(-24°)到 0.418 弧度(24°)之间
-
杆角速度,在-Inf 到 Inf 之间
例如,我们可以有一个observation为[0.33676587, 0.3786464, -0.00170739, -0.36586074]。
有关 CartPole-v1 环境的更多信息,请访问gymnasium.farama.org/environments/classic_control/cart_pole/。
在我们提出的解决方案中,我们将在每个时间步骤使用这些值作为输入,以决定采取什么行动。我们将借助基于神经网络的控制器来实现这一点。详细描述见下一个小节。
使用神经网络控制 CartPole
为了成功地完成CartPole挑战,我们希望能够动态响应环境的变化。例如,当杆子开始向一个方向倾斜时,我们可能应该把小车朝那个方向移动,但当杆子开始稳定时,可能需要停止推动。因此,这里的强化学习任务可以被看作是教一个控制器通过将四个可用的输入——小车位置、小车速度、杆子角度和杆子速度——映射到每个时间步的适当动作,来保持杆子的平衡。我们如何实现这种映射呢?
实现这种映射的一个好方法是使用神经网络。正如我们在第九章《深度学习网络的架构优化》中看到的那样,神经网络,比如多层感知器(MLP),可以实现其输入和输出之间的复杂映射。这个映射是通过网络的参数来完成的——即,网络中活跃节点的权重和偏置,以及这些节点实现的传递函数。在我们的案例中,我们将使用一个包含四个节点的单一隐藏层的网络。此外,输入层由四个节点组成,每个节点对应环境提供的一个输入值,而输出层则有一个节点,因为我们只有一个输出值——即需要执行的动作。这个网络结构可以通过以下图示来表示:
图 10.7:用于控制小车的神经网络结构
正如我们已经看到的,神经网络的权重和偏置值通常是在网络训练的过程中设置的。值得注意的是,到目前为止,我们仅仅看到了在使用反向传播算法实施监督学习的过程中训练神经网络——也就是说,在之前的每一种情况中,我们都有一组输入和匹配的输出,网络被训练来将每个给定的输入映射到其匹配的输出。然而,在这里,当我们实践强化学习时,我们并没有这种训练信息。相反,我们只知道网络在每一轮训练结束时的表现如何。这意味着我们需要一种方法来根据通过运行环境的训练轮次获得的结果来找到最佳的网络参数——即权重和偏置,而不是使用传统的训练算法。这正是遗传算法擅长的优化任务——找到一组能够为我们提供最佳结果的参数,只要你有评估和比较这些参数的方法。为了做到这一点,我们需要弄清楚如何表示网络的参数,并且如何评估一组给定的参数。这两个问题将在下一个小节中讨论。
解决方案表示与评估
由于我们决定使用MLP类型的神经网络来控制 CartPole 挑战中的小车,因此我们需要优化的参数集合为网络的权重和偏置,具体如下:
-
输入层:该层不参与网络映射;相反,它接收输入值并将其传递给下一层的每个神经元。因此,这一层不需要任何参数。
-
隐藏层:这一层中的每个节点与四个输入完全连接,因此除了一个偏置值外,还需要四个权重。
-
输出层:这一层的单个节点与隐藏层中的每个四个节点相连,因此除了一个偏置值外,还需要四个权重。
总共有 20 个权重值和 5 个偏置值需要找到,所有值都为float类型。因此,每个潜在解决方案可以表示为 25 个float值的列表,如下所示:
[0.9505049282421143, -0.8068797228337171, -0.45488246459260073, ... ,0.6720551701599038]
评估给定的解决方案意味着创建一个具有正确维度的 MLP——四个输入,一个四节点的隐藏层和一个输出——并将我们浮动列表中的权重和偏置值分配到不同的节点上。然后,我们需要使用这个 MLP 作为小车摆杆的控制器,运行一个回合。回合的总奖励作为此解决方案的得分值。与之前的任务相比,在这里我们旨在最大化得分。这个得分评估过程由CartPole类实现,接下来将深入讨论。
Python 问题表示
为了封装CartPole挑战,我们创建了一个名为CartPole的 Python 类。该类包含在cart_pole.py文件中,位于github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_10/cart_pole.py。
该类通过一个可选的随机种子初始化,并提供以下方法:
-
initMlp():使用所需的网络架构(层)和网络参数(权重和偏置)初始化一个 MLP 回归器,这些参数来自表示候选解决方案的浮动列表。
-
getScore():计算给定解决方案的得分,该解决方案由一组浮点值表示的网络参数表示。通过创建一个相应的 MLP 回归器,初始化CartPole环境的一个回合,并在使用观察作为输入的同时,利用 MLP 控制行动来实现这一点。得分越高,效果越好。
-
saveParams():使用pickle序列化并保存网络参数列表。
-
replayWithSavedParams():反序列化最新保存的网络参数列表,并使用这些参数通过replay方法重放一个回合。
-
replay():渲染环境,并使用给定的网络参数重放一个回合,展示给定的解决方案。
类的主要方法应该在解决方案已序列化并保存后使用,使用saveParams()方法。主方法将初始化类并调用replayWithSavedParams()来渲染并动画化保存的解决方案。
我们通常会使用主方法来动画化遗传算法驱动的解决方案所找到的最佳解决方案,正如下面小节所探讨的那样。
遗传算法解决方案
为了与CartPole环境进行交互并使用遗传算法来解决它,我们创建了一个 Python 程序02_solve_cart-pole.py,该程序位于github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_10/02_solve_cart_pole.py。
由于我们将使用浮动数值列表来表示解决方案——即网络的权重和偏差——这个程序与我们在第六章中看到的函数优化程序非常相似,优化连续函数,例如我们用于Eggholder 函数优化的程序。
以下步骤描述了如何创建此程序的主要部分:
-
我们首先创建一个CartPole类的实例,这将使我们能够测试CartPole挑战的各种解决方案:
cartPole = cart_pole.CartPole(RANDOM_SEED)- 接下来,我们设置浮动数值的上下边界。由于我们所有的数值表示神经网络中的权重和偏差,因此这个范围应该在每个维度内都介于-1.0 和 1.0 之间:
BOUNDS_LOW, BOUNDS_HIGH = -1.0, 1.0- 如你所记得,我们在这个挑战中的目标是最大化分数——即我们能保持杆子平衡的时间。为此,我们定义了一个单一目标,最大化适应度策略:
creator.create("FitnessMax", base.Fitness, weights=(1.0,))- 现在,我们需要创建一个辅助函数,用于在给定范围内均匀分布地生成随机实数。此函数假设每个维度的范围都是相同的,就像我们解决方案中的情况一样:
def randomFloat(low, up): return [random.uniform(l, u) for l, u in zip([low] * \ NUM_OF_PARAMS, [up] * NUM_OF_PARAMS)]- 现在,我们使用此函数创建一个操作符,它会随机返回一个在我们之前设定的范围内的浮动数值列表:
toolbox.register("attrFloat", randomFloat, BOUNDS_LOW, BOUNDS_HIGH)- 紧接着是一个操作符,使用之前的操作符填充个体实例:
toolbox.register("individualCreator", tools.initIterate, creator.Individual, toolbox.getScore() method, which we described in the previous subsection, initiates an episode of the *CartPole* environment. During this episode, the cart is controlled by a single-hidden layer MLP. The weight and bias values of this MLP are populated by the list of floats representing the current solution. Throughout the episode, the MLP dynamically maps the observation values of the environment to an action of *right* or *left*. Once the episode is done, the score is set to the total reward, which equates to the number of time steps that the MLP was able to keep the pole balanced – the higher, the better:def score(individual):
return cartPole.getScore(individual),
toolbox.register("evaluate", score)
- 现在是选择遗传操作符的时候了。我们将再次使用锦标赛选择,并且锦标赛大小为 2,作为我们的选择操作符。由于我们的解决方案表示为一个在给定范围内的浮动数值列表,我们将使用 DEAP 框架提供的专用连续有界交叉和变异操作符——分别是cxSimulatedBinaryBounded和mutPolynomialBounded:
toolbox.register("select", tools.selTournament, tournsize=2) toolbox.register("mate", tools.cxSimulatedBinaryBounded, low=BOUNDS_LOW, up=BOUNDS_HIGH, eta=CROWDING_FACTOR) toolbox.register("mutate", tools.mutPolynomialBounded, low=BOUNDS_LOW, up=BOUNDS_HIGH, eta=CROWDING_FACTOR, indpb=1.0/NUM_OF_PARAMS)另外,像往常一样,我们使用精英策略,即当前最好的个体——HOF 成员——始终会直接传递到下一代:
population, logbook = elitism.eaSimpleWithElitism( population, toolbox, cxpb=P_CROSSOVER, mutpb=P_MUTATION, ngen=MAX_GENERATIONS, stats=stats, halloffame=hof, verbose=True)运行结束后,我们打印出最佳解并保存,以便通过我们在MountainCar类中构建的回放功能进行动画演示:
best = hof.items[0] print("Best Solution = ", best) print("Best Score = ", best.fitness.values[0]) cartPole.saveParams(best)此外,我们将使用我们最好的个体运行 100 次连续的实验,每次都随机初始化 CartPole 问题,因此每个实验都从稍微不同的起始条件开始,可能会得到不同的结果。然后我们将计算所有结果的平均值:
scores = [] for test in range(100): scores.append(cart_pole.CartPole().getScore(best)) print("scores = ", scores) print("Avg. score = ", sum(scores) / len(scores))
现在是时候看看我们在这个挑战中表现得如何了。通过运行 10 代,每代 30 个个体的遗传算法,我们得到了以下结果:
gen nevals max avg
0 30 68 14.4333
1 26 77 21.7667
...
4 27 381 57.2667
5 26 500 105.733
...
9 22 500 207.133
10 26 500 293.267
Best Solution = [-0.7441543221198176, 0.34598771744315737, -0.4221171254602347, ...
Best Score = 500.0
从前面的输出中可以看到,在仅仅五代之后,最好的解达到了 500 的最高分,在整个实验期间平衡了杆子。
从我们额外测试的结果来看,似乎所有 100 次测试都以完美的 500 分结束:
Running 100 episodes using the best solution...
scores = [500.0, 500.0, 500.0, 500.0, 500.0, 500.0, 500.0, 500.0, 500.0, 500.0, 500.0, 500.0, ... , 500.0]
Avg. score = 500.0
正如我们之前提到的,每次这 100 次实验都以略有不同的随机起始点开始。然而,控制器足够强大,可以每次都在整个实验过程中保持杆子的平衡。为了观察控制器的实际效果,我们可以通过启动cart_pole程序来播放 CartPole 实验——或播放多个实验——并查看保存的结果。动画展示了控制器如何通过采取行动动态地响应杆子的运动,使其在整个实验过程中保持在小车上的平衡。
如果你想将这些结果与不完美的结果进行对比,建议你在CartPole类中将HIDDEN_LAYER常量的值改为三(甚至两个)个节点,而不是四个。或者,你可以减少遗传算法的代数和/或种群规模。
总结
在本章中,你了解了强化学习的基本概念。在熟悉了Gymnasium工具包后,你遇到了MountainCar挑战,在这个挑战中,需要控制一辆车使其能够爬上两座山中的较高一座。在使用遗传算法解决了这个挑战后,你接着遇到了下一个挑战——CartPole,在这个挑战中,需要精确控制一辆小车以保持竖直的杆子平衡。我们通过结合基于神经网络的控制器和遗传算法引导的训练成功解决了这个挑战。
虽然我们迄今为止主要关注的是涉及结构化数值数据的问题,但下一章将转向遗传算法在自然语言处理(NLP)中的应用,这是机器学习的一个分支,使计算机能够理解、解释和处理人类语言。
进一步阅读
欲了解更多信息,请参考以下资源:
-
用 Python 精通强化学习,Enes Bilgin,2020 年 12 月 18 日
-
深度强化学习实战,第 2 版,Maksim Lapan,2020 年 1 月 21 日
-
Gymnasium 文档:
-
OpenAI Gym(白皮书),Greg Brockman,Vicki Cheung,Ludwig Pettersson,Jonas Schneider,John Schulman,Jie Tang,Wojciech Zaremba:
第十一章:自然语言处理
本章探讨了遗传算法如何增强自然语言处理(NLP)任务的性能,并深入了解其潜在机制。
本章通过介绍 NLP 领域并解释词嵌入的概念开始。我们运用这一技术,利用遗传算法来玩类似Semantle的神秘词游戏,挑战算法猜测神秘词。
随后,我们研究了n-gram和文档分类。我们利用遗传算法来确定一个紧凑而有效的特征子集,揭示分类器的运作原理。
到本章结束时,你将达到以下目标:
-
熟悉 NLP 领域及其应用
-
理解了词嵌入的概念及其重要性
-
使用词嵌入实现了一个神秘词游戏,并创建了一个由遗传算法驱动的玩家来猜测神秘词
-
获取了有关 n-gram 及其在文档处理中的作用的知识
-
开发了一种过程,显著减少了用于消息分类的特征集大小
-
使用最小特征集来洞察分类器的运作
本章将以快速概述自然语言处理(NLP)开始。如果你是经验丰富的数据科学家,可以跳过引言部分。
技术要求
本章将使用 Python 3,并配备以下支持库:
-
deap
-
numpy
-
pandas
-
matplotlib
-
seaborn
-
scikit-learn
-
gensim——在本章中介绍
重要提示
如果你使用我们提供的requirements.txt文件(参见第三章),这些库已经包含在你的环境中。
本章的代码可以在这里找到:
查看以下视频,看看代码如何运行:
理解 NLP
自然语言处理(NLP)是人工智能的一个迷人分支,专注于计算机与人类语言之间的互动。NLP 结合了语言学、计算机科学和机器学习,使机器能够理解、解释和生成有意义且有用的人类语言。在过去几年中,NLP 在我们的日常生活中扮演着越来越重要的角色,应用范围涵盖多个领域,从虚拟助手和聊天机器人到情感分析、语言翻译和信息检索等。
自然语言处理(NLP)的主要目标之一是弥合人类与机器之间的沟通鸿沟;这是至关重要的,因为语言是人们进行互动和表达思想、观点和愿望的主要媒介。弥合人类与机器之间沟通鸿沟的目标推动了 NLP 领域的显著进展。最近这一领域的一个重要里程碑是大型语言模型(LLMs)的开发,例如 OpenAI 的ChatGPT。
为了创造人机沟通的桥梁,必须有一种方法能够将人类语言转化为数值表示,使机器能够更有效地理解和处理文本数据。一个这样的技术就是使用词嵌入,在下一节中将对此进行描述。
词嵌入
词嵌入是英语(或其他语言)中单词的数值表示。每个单词都使用一个固定长度的实数向量进行编码。这些向量有效地捕捉了与它们所表示的单词相关的语义和上下文信息。
词嵌入是通过训练神经网络(NNs)来创建单词的数值表示,这些神经网络从大量的书面或口语文本中学习,其中具有相似上下文的单词会映射到连续向量空间中的相邻点。
创建词嵌入的常见技术包括Word2Vec、全局词向量表示(GloVe)和fastText。
词嵌入的典型维度可以变化,但常见的选择是 50、100、200 或 300 维。更高维度的嵌入可以捕捉到更多细微的关系,但可能需要更多的数据和计算资源。
例如,“dog”这个词在 50 维的 Word2Vec 嵌入空间中的表示可能如下所示:
[0.11008 -0.38781 -0.57615 -0.27714 0.70521 0.53994 -1.0786 -0.40146 1.1504 -0.5678 0.0038977 0.52878 0.64561 0.47262 0.48549 -0.18407 0.1801 0.91397 -1.1979 -0.5778 -0.37985 0.33606 0.772 0.75555 0.45506 -1.7671 -1.0503 0.42566 0.41893 -0.68327 1.5673 0.27685 -0.61708 0.64638 -0.076996 0.37118 0.1308 -0.45137 0.25398 -0.74392 -0.086199 0.24068 -0.64819 0.83549 1.2502 -0.51379 0.04224 -0.88118 0.7158 0.38519]
这些 50 个值中的每一个代表了在训练数据上下文中“dog”这个词的不同方面。相关的词汇,如“cat”或“pet”,在这个空间中的词向量会接近“dog”向量,表示它们在语义上的相似性。这些嵌入不仅捕捉了语义信息,还保持了单词之间的关系,使得 NLP 模型能够理解单词关系、上下文,甚至句子和文档级的语义。
下图是 50 维向量的二维可视化,代表了各种英语单词。此图是使用t-分布随机邻域嵌入(t-SNE)创建的,t-SNE 是一种常用于可视化和探索词嵌入的降维技术。t-SNE 将词嵌入投影到一个低维空间,同时保持数据点之间的关系和相似性。此图展示了某些单词组(例如水果或动物)之间的接近关系。单词之间的关系也显而易见——例如,“son”和“boy”之间的关系类似于“daughter”和“girl”之间的关系:
图 11.1:词嵌入的二维 t-SNE 图
除了在自然语言处理中的传统作用,词嵌入还可以应用于遗传算法,正如我们在下一节中将看到的那样。
词嵌入和遗传算法
在本书的前几章中,我们实现了多个使用固定长度实值向量(或列表)作为候选解染色体表示的遗传算法示例。鉴于词嵌入使我们能够使用固定长度的实值数字向量来表示单词(如“dog”),这些向量可以有效地作为遗传算法应用中的单词遗传表示。
这意味着我们可以利用遗传算法来解决候选解是英语单词的问题,利用词嵌入作为单词及其遗传表示之间的翻译机制。
为了展示这一概念,我们将通过一个有趣的词汇游戏来演示如何使用遗传算法解决问题,如下节所述。
使用遗传算法找出谜底词
近年来,在线谜词游戏获得了显著的流行。其中一个突出的例子是Semantle,这是一款根据词义来挑战你猜测每日词汇的游戏。
这款游戏会根据你猜测的词与目标词的语义相似度提供反馈,并且具有一个“热与冷”指示器,显示你的猜测与秘密词的接近程度。
在幕后,Semantle 使用词嵌入,特别是 Word2Vec 来表示谜词和玩家的猜测。它通过计算它们表示之间的差异来衡量它们的语义相似度:向量越接近,词汇之间的相似度就越高。游戏返回的相似度分数范围从-100(与答案差异很大)到 100(与答案完全相同)。
在接下来的子章节中,我们将创建两个 Python 程序。第一个程序模拟了 Semantle 游戏,另一个程序则是一个由遗传算法驱动的玩家或解算器,旨在通过最大化游戏的相似度分数来揭示谜底。两个程序都依赖于词嵌入模型;然而,为了保持清晰的区分,模拟现实世界的场景,每个程序都使用其独特的模型。玩家和游戏之间的互动仅限于交换实际的猜测单词及其对应的分数,且不交换嵌入向量。以下是示意图:
图 11.2:Python 模块的组件图及其交互
为了增加额外的神秘感,我们决定使每个程序使用完全不同的嵌入模型。为了使其工作,我们假设两个嵌入模型在词汇表中有显著的重叠。
下一节详细介绍了这些程序的 Python 实现。
Python 实现
我们将首先使用gensim库创建单词嵌入模型的 Python 实现,如下一小节所述。
gensim 库
gensim库是一个多才多艺的 Python 包,主要用于自然语言处理和文本分析任务。gensim通过提供一整套工具,使得处理单词向量的创建、训练和使用变得高效。其主要特点之一是作为预训练单词嵌入模型的提供者,我们将在第一个 Python 模块中利用它,如下所述。
Embeddings 类
我们从一个名为Embeddings的 Python 类开始,该类封装了基于gensim的预训练单词嵌入模型。可以在以下链接找到这个类,它位于embeddings.py文件中:
此类的主要功能如下所示:
-
类的 init() 方法初始化随机种子(如果有),然后使用 _init_model() 和 _download_and_save_model() 私有方法初始化选择的(或默认的)gensim模型。前者从本地文件上传模型的嵌入信息(如果可用)。否则,后者从gensim仓库下载模型,分离用于嵌入的关键部分KeyedVectors,并将其保存在本地以便下次使用:
if not isfile(model_path): self._download_and_save_model(model_path) print(f"Loading model '{self.model_name}' from local file...") self.model = KeyedVectors.load_word2vec_format(model_path, binary=True) -
pick_random_embedding() 方法可用于从模型的词汇表中随机选择一个词。
-
get_similarity() 方法用于检索模型在两个指定词之间的相似性值。
-
vec2_nearest_word() 方法利用 gensim 模型的 similar_by_vector() 方法检索与指定嵌入向量最接近的词。很快我们将看到,这使得遗传算法可以使用任意向量(例如随机生成的向量),并使它们代表模型词汇表中的现有词。
-
最后,list_models() 方法可用于检索和显示gensim库提供的可用嵌入模型的信息。
如前所述,这个类被Player和Game组件共同使用,将在下一小节中讨论。
MysteryWordGame 类
MysteryWordGame Python 类封装了 Game 组件。它可以在以下链接的 mystery_word_game.py 文件中找到:
该类的主要功能如下:
-
该类使用了斯坦福大学开发的glove-twitter-50 gensim 预训练嵌入模型。该模型专门为 Twitter 文本数据设计,使用了 50 维的嵌入向量。
-
该类的 init() 方法初始化它将内部使用的嵌入模型,然后随机选择一个神秘单词或使用作为参数传递的指定单词:
self.embeddings = Embeddings(model_name=MODEL) self.mystery_word = given_mystery_word if given_mystery_word else self.embeddings.pick_random_embedding() -
score_guess() 方法计算游戏返回的给定猜测单词的得分。如果该单词不在模型的词汇表中(可能是因为玩家模块使用了一个可能不同的模型),则得分设置为最小值 -100。否则,计算出的得分值将是一个介于 -100 和 100 之间的数字:
if self.embeddings.has_word(guess_word): score = 100 * self.embeddings.get_similarity(self.mystery_word, guess_word) else: score = -100 -
main() 方法通过创建游戏的实例来测试该类的功能,选择单词 "dog",并评估与其相关的多个猜测单词,例如 "canine" 和 "hound"。它还包括一个不相关的单词("computer")和一个在词汇表中不存在的单词("asdghf"):
game = MysteryWordGame(given_mystery_word="dog") print("-- Checking candidate guess words:") for guess_word in ["computer", "asdghf", "canine", "hound", "poodle", "puppy", "cat", "dog"]: score = game.score_guess(guess_word) print(f"- current guess: {guess_word.ljust(10)} => score = {score:.2f}")
执行该类的 main() 方法会产生以下输出:
Loading model 'glove-twitter-50' from local file...
--- Mystery word is 'dog' — game on!
-- Checking candidate guess words:
- current guess: computer => score = 54.05
- current guess: asdghf => score = -100.00
- current guess: canine => score = 47.07
- current guess: hound => score = 64.93
- current guess: poodle => score = 65.90
- current guess: puppy => score = 87.90
- current guess: cat => score = 94.30
- current guess: dog => score = 100.00
我们现在已经准备好进入有趣的部分——试图解决游戏的程序。
基于遗传算法的玩家程序
如前所述,该模块使用了与游戏中使用的模型不同的嵌入模型,尽管它也可以选择使用相同的模型。在这种情况下,我们选择了 glove-wiki-gigaword-50 gensim 预训练嵌入模型,该模型是在来自英语 Wikipedia 网站和 Gigaword 数据集的大量语料库上训练的。
解的表示
在此案例中,遗传算法中的解表示为一个实值向量(或列表),其维度与嵌入模型相同。这使得每个解可以作为一个嵌入向量,尽管并不完全完美。最初,算法使用随机生成的向量,并通过交叉和变异操作,至少可以保证部分向量不会直接与模型词汇中的现有单词对应。为了解决这个问题,我们使用 Embedding 类中的 vec2_nearest_word() 方法,该方法返回词汇中最接近的单词。这种方法体现了基因型到表型映射的概念,如在第四章《组合优化》中讨论的那样。
早期收敛标准
在迄今讨论的大多数情况下,解决方案并不知道在优化过程中可以达到的最佳得分。然而,在这种情况下,我们知道最佳得分是 100。一旦达到,就找到了正确的单词,继续进化循环就没有意义了。因此,我们修改了遗传算法的主循环以在达到最大分数时中断。修改后的方法称为 eaSimple_modified(),可以在 elitism_modified.py 模块中找到。它接受一个名为 max_fitness 的可选参数。当此参数提供了一个值时,如果迄今为止找到的最佳适应度值达到或超过此值,则主循环中断:
if max_fitness and halloffame.items[0].fitness.values[0] >=
max_fitness:
break
打印出当前猜测最佳单词
此外,eaSimple_modified() 方法包括打印与迄今为止找到的最佳适应度个体对应的猜测单词,作为为每个个体生成的统计摘要的一部分:
if verbose:
print(f"{logbook.stream} => {embeddings.vec2_nearest_word(
np.asarray(halloffame.items[0]))}")
遗传算法实现
基于遗传算法的玩家为神秘单词游戏寻找最佳超参数值,由位于以下链接的 01_find_mystery_word.py Python 程序实现:
以下步骤描述了这个程序的主要部分:
-
我们首先创建一个 Embeddings 类的实例,它将作为解决程序的词嵌入模型:
embeddings = Embeddings(model_name='glove-wiki-gigaword-50', randomSeed=RANDOM_SEED) VECTOR_SIZE = embeddings.get_vector_size() -
接下来,我们创建 MysteryWordGame 类的一个实例,代表我们将要玩的游戏。我们指示它使用单词“dog”作为演示目的。稍后可以用其他单词替换这个词,或者如果省略 given_mystery_word 参数,我们可以让游戏选择一个随机单词:
game = MysteryWordGame(given_mystery_word='dog') -
由于我们的目标是最大化游戏的得分,我们定义了一个单目标策略来最大化适应度:
creator.create("FitnessMax", base.Fitness, weights=(1.0,)) -
要创建表示词嵌入的随机个体,我们创建一个 randomFloat() 函数,并将其注册到工具箱中:
def randomFloat(low, up): return [random.uniform(l, u) for l, u in zip([low] * VECTOR_SIZE, [up] * VECTOR_SIZE)] toolbox.register("attrFloat", randomFloat, BOUNDS_LOW, BOUNDS_HIGH) -
score() 函数用于评估每个解决方案的适应度,这个过程包括两个步骤:首先,我们使用本地 embeddings 模型找到评估向量最接近的词汇单词(这是基因型到表现型映射发生的地方)。接下来,我们将这个词汇发送到 Game 组件,并请求其评分作为猜测的单词。游戏返回的分数,一个从 -100 到 100 的值,直接用作适应度值:
def score(individual): guess_word = embeddings.vec2_nearest_word( np.asarray(individual)) return game.score_guess(guess_word), toolbox.register("evaluate", score) -
现在,我们需要定义遗传操作符。对于选择操作符,我们使用常见的tournament selection(锦标赛选择),锦标赛大小为 2;而对于交叉和变异操作符,我们选择专门针对有界浮动列表染色体的操作符,并为每个超参数定义了相应的边界:
toolbox.register("select", tools.selTournament, tournsize=2) toolbox.register("mate", tools.cxSimulatedBinaryBounded, low=BOUNDS_LOW, up=BOUNDS_HIGH, eta=CROWDING_FACTOR) toolbox.register("mutate", tools.mutPolynomialBounded, low=BOUNDS_LOW, up=BOUNDS_HIGH, eta=CROWDING_FACTOR, indpb=1.0 / NUM_OF_PARAMS) -
此外,我们继续使用精英主义方法,其中名人堂(HOF)成员——当前最优个体——始终被无修改地传递到下一代。然而,在本次迭代中,我们使用了eaSimple_modified算法,其中——此外——主循环将在得分达到已知的最高分时终止:
population, logbook = eaSimple_modified( population, toolbox, cxpb=P_CROSSOVER, mutpb=P_MUTATION, ngen=MAX_GENERATIONS, max_fitness=MAX_SCORE, stats=stats, halloffame=hof, verbose=True)
通过运行算法,种群大小为 30,得到了以下结果:
Loading model 'glove-wiki-gigaword-50' from local file...
Loading model 'glove-twitter-50' from local file...
--- Mistery word is 'dog' — game on!
gen nevals max avg
0 30 51.3262 -43.8478 => stories
1 25 51.3262 -17.5409 => stories
2 26 51.3262 -1.20704 => stories
3 26 51.3262 11.1749 => stories
4 26 64.7724 26.23 => bucket
5 25 64.7724 40.0518 => bucket
6 26 67.487 42.003 => toys
7 26 69.455 37.0863 => family
8 25 69.455 48.1514 => family
9 25 69.455 38.5332 => family
10 27 87.2265 47.9803 => pet
11 26 87.2265 46.3378 => pet
12 27 87.2265 40.0165 => pet
13 27 87.2265 52.6842 => pet
14 26 87.2265 59.186 => pet
15 27 87.2265 41.5553 => pet
16 27 87.2265 49.529 => pet
17 27 87.2265 50.9414 => pet
18 27 87.2265 44.9691 => pet
19 25 87.2265 30.8624 => pet
20 27 100 63.5354 => dog
Best Solution = dog
Best Score = 100.00
从这个输出中,我们可以观察到以下几点:
-
加载了两个不同的词嵌入模型,一个用于玩家,另一个用于游戏,按照设计进行。
-
设置为**‘狗’**的神秘词汇,在 20 代之后被遗传算法驱动的玩家正确猜测。
-
一旦找到词汇,玩家就停止了游戏,尽管最大代数设置为 1000。
-
我们可以看到当前最优猜测词汇的演变过程:
-
故事 → 桶 → 玩具 → 家庭 → 宠物 → 狗
这看起来很棒!不过,请记住这只是一个示例。我们鼓励你尝试其他词汇,以及调整遗传算法的不同设置;也许还可以改变嵌入模型。是否有某些模型对比其他模型兼容性差呢?
本章的下一部分,我们将探索文档分类。
文档分类
文档分类是自然语言处理中的一项关键任务,涉及根据文本内容将文档分到预定义的类别或类目中。这个过程对于组织、管理和从大量文本数据中提取有意义的信息至关重要。文档分类的应用广泛,涵盖各行各业和领域。
在信息检索领域,文档分类在搜索引擎中扮演着至关重要的角色。通过将网页、文章和文档分类到相关的主题或类型,搜索引擎可以为用户提供更精确、更有针对性的搜索结果。这提升了整体用户体验,确保用户能够快速找到所需信息。
在客户服务和支持中,文档分类能够实现自动路由客户咨询和信息到相关部门或团队。例如,公司收到的电子邮件可以分类为“账单查询”、“技术支持”或“一般咨询”,确保每一条消息都能及时传达给正确的团队进行处理。
在法律领域,文档分类在电子发现等任务中至关重要,其中需要分析大量的法律文档以确定它们是否与案件相关。分类有助于识别潜在与法律事务相关的文档,从而简化审查过程,减少法律程序所需的时间和资源。
此外,文档分类在情感分析中至关重要,可以用来将社交媒体帖子、评论和意见分类为正面、负面或中性情感。这些信息对于希望评估客户反馈、监控品牌声誉并做出数据驱动决策以改进产品或服务的企业来说是无价的。
执行文档分类的一个有效方法是利用 n-gram,详细内容将在接下来的部分中讲解。
N-gram
n-gram 是由n个项目组成的连续序列,这些项目可以是字符、单词或甚至短语,从更大的文本中提取出来。通过将文本分解成这些较小的单位,n-gram 能够提取出有价值的语言模式、关系和语境。
例如,在字符 n-gram的情况下,3-gram 可能会将单词“apple”拆分为“app”,“ppl”和“ple”。
这里有一些单词 n-gram的示例:
-
单元组(1-gram):
文本:“我爱编程。”
单元组:[“我”,“爱”,“编程”]
-
二元组(2-gram):
文本:“自然语言处理是迷人的。”
二元组:[“自然语言”,“语言处理”,“处理是”,“是迷人”]
-
三元组(3-gram):
文本:“机器学习模型可以泛化。”
三元组:[“机器学习模型”,“学习模型可以”,“模型可以泛化”]
N-gram 通过揭示单词或字符的顺序排列,识别频繁的模式并提取特征,从而提供对文本内容的洞察。它们有助于理解语言结构、语境和模式,使其在文档分类等文本分析任务中非常有价值。
选择 n-gram 的一个子集
在第七章,“通过特征选择增强机器学习模型”中,我们展示了选择有意义特征子集的重要性,这一过程在文档分类中同样具有价值,尤其是当处理大量提取的 n-gram 时,这在大型文档中很常见。识别相关 n-gram 子集的优势包括以下几点:
-
降维:减少 n-gram 的数量可以提高计算效率,防止过拟合
-
关注关键特征:选择具有区分性的 n-gram 有助于模型集中关注关键特征
-
噪声减少:过滤掉无信息的 n-gram 最小化数据中的噪声
-
增强泛化能力:精心选择的子集提高了模型处理新文档的能力
-
效率:较小的特征集加速模型的训练和预测
此外,在文档分类中识别相关的 n-gram 子集对于模型可解释性非常重要。通过将特征缩小到一个可管理的子集,理解和解释影响模型预测的因素变得更加容易。
类似于我们在第七章中所做的,我们将在这里应用基因算法搜索,以识别相关的 n-gram 子集。然而,考虑到我们预期的 n-gram 数量远大于我们之前使用的常见数据集中的特征数量,我们不会寻找整体最好的子集。相反,我们的目标是找到一个固定大小的特征子集,例如最好的 1,000 个或 100 个 n-gram。
使用基因算法搜索固定大小的子集
由于我们需要在一个非常大的群体中识别一个良好的、固定大小的子集,下面我们尝试定义基因算法所需的常见组件:
-
解空间表示:由于子集的大小远小于完整数据集,因此使用表示大数据集中项索引的固定大小整数列表更加高效。例如,如果我们旨在从 100 个项目中创建一个大小为 3 的子集,则一个可能的解决方案可以表示为列表,如[5, 42, 88]或[73, 11, 42]。
-
交叉操作:为了确保有效的后代,我们必须防止同一个索引在每个后代中出现多次。在前面的示例中,项“42”出现在两个列表中。如果我们使用单点交叉,可能会得到后代[5, 42, 42],这实际上只有两个唯一的项,而不是三个。一种克服这个问题的简单交叉方法如下:
-
创建一个集合,包含两个父代中所有唯一的项。在我们的示例中,这个集合是{5, 11, 42, 73, 88}。
-
通过从前面提到的集合中随机选择生成后代。每个后代应该选择三个项(在本例中)。可能的结果可以是[5, 11, 88]和[11, 42, 88]。
-
-
变异操作:生成一个有效变异个体的简单方法如下:
-
对于列表中的每个项,按指定的概率,将该项替换为当前列表中存在的项。
-
例如,如果我们考虑列表[11, 42, 88],则有可能将第二个项(42)替换为 27,得到列表[11, 27, 88]。
-
Python 实现
在接下来的章节中,我们将实现以下内容:
-
一个文档分类器,它将在来自两个新闻组的文档数据上训练,并使用 n-gram 来预测每个文档属于哪个新闻组
-
一个由基因算法驱动的优化器,旨在根据所需的子集大小,寻找用于此分类任务的最佳 n-gram 子集
我们将从实现分类器的类开始,如下一个子章节所述:
新闻组文档分类器
我们从一个名为NewsgroupClassifier的 Python 类开始,实现一个基于scikit-learn的文档分类器,该分类器使用 n-gram 作为特征,并学习区分来自两个不同新闻组的帖子。该类可以在newsgroup_classifier.py文件中找到,该文件位于以下链接:
该类的主要功能如下所示:
-
该类的init_data()方法由init()调用,从scikit-learn的内置新闻组数据集中创建训练集和测试集。它从两个类别**'rec.autos'和'rec.motorcycles'**中检索帖子,并进行预处理,去除标题、页脚和引用:
categories = ['rec.autos', 'rec.motorcycles'] remove = ('headers', 'footers', 'quotes') newsgroups_train = fetch_20newsgroups(subset='train', categories=categories, remove=remove, shuffle=False) newsgroups_test = fetch_20newsgroups(subset='test', categories=categories, remove=remove, shuffle=False) -
接下来,我们创建两个TfidfVectorizer对象:一个使用 1 到 3 个词的词 n-gram,另一个使用 1 到 10 个字符的字符 n-gram。这些向量化器根据每个文档中 n-gram 的相对频率与整个文档集合进行比较,将文本文档转换为数值特征向量。然后,这两个向量化器被合并成一个vectorizer实例,以从提供的新闻组消息中提取特征:
word_vectorizer = TfidfVectorizer(analyzer='word', sublinear_tf=True, max_df=0.5, min_df=5, stop_words="english", ngram_range=(1, 3)) char_vectorizer = TfidfVectorizer(analyzer='char', sublinear_tf=True, max_df=0.5, min_df=5, ngram_range=(1, 10)) vectorizer = FeatureUnion([('word_vectorizer', word_vectorizer), ('char_vectorizer', char_vectorizer)]) -
我们通过允许vectorizer实例从训练数据中“学习”相关的 n-gram 信息,然后将训练数据和测试数据转换为包含相应 n-gram 特征的向量数据集:
self.X_train = vectorizer.fit_transform(newsgroups_train.data) self.y_train = newsgroups_train.target self.X_test = vectorizer.transform(newsgroups_test.data) self.y_test = newsgroups_test.target -
get_predictions()方法生成“简化”版本的训练集和测试集,利用通过features_indices参数提供的特征子集。随后,它使用MultinomialNB的一个实例,这是一个在文本分类中常用的分类器,它在简化后的训练集上进行训练,并为简化后的测试集生成预测:
reduced_X_train = self.X_train[:, features_indices] reduced_X_test = self.X_test[:, features_indices] classifier = MultinomialNB(alpha=.01) classifier.fit(reduced_X_train, self.y_train) return classifier.predict(reduced_X_test) -
**get_accuracy()和get_f1_score()方法使用get_predictions()**方法来分别计算和返回分类器的准确率和 f-score:
-
main()方法产生以下输出:Initializing newsgroup data... Number of features = 51280, train set size = 1192, test set size = 794 f1 score using all features: 0.8727376310606889 f1 score using random subset of 100 features: 0.589931144127823
我们可以看到,使用所有 51,280 个特征时,分类器能够达到 0.87 的 f1-score,而使用 100 个随机特征子集时,得分降至 0.59。让我们看看使用遗传算法选择特征子集是否能帮助我们接近更高的得分。
使用遗传算法找到最佳特征子集
基于遗传算法的搜索,用于寻找 100 个特征的最佳子集(从原始的 51,280 个特征中挑选),是通过02_solve_newsgroups.py Python 程序实现的,程序位于以下链接:
以下步骤描述了该程序的主要部分:
-
我们通过创建NewsgroupClassifier类的一个实例,来测试不同的固定大小特征子集:
ngc = NewsgroupClassifier(RANDOM_SEED) -
然后,我们定义了两个专门的固定子集遗传操作符,cxSubset()——实现交叉——和mutSubset()——实现变异,正如我们之前所讨论的那样。
-
由于我们的目标是最大化分类器的 f1 分数,我们定义了一个单一目标策略来最大化适应度:
creator.create("FitnessMax", base.Fitness, weights=(1.0,)) -
为了创建表示特征索引的随机个体,我们创建了**randomOrder()函数,该函数利用random.sample()**在 51,280 的期望范围内生成一个随机的索引集。然后,我们可以使用这个函数来创建个体:
toolbox.register("randomOrder", random.sample, range(len(ngc)), SUBSET_SIZE) toolbox.register("individualCreator", tools.initIterate, creator.Individual, toolbox.randomOrder) -
get_score()函数用于评估每个解(或特征子集)的适应度,它通过调用NewsgroupClassifier实例的**get_f1_score()**方法来实现:
def get_score(individual): return ngc.get_f1_score(individual), toolbox.register("evaluate", get_score) -
现在,我们需要定义遗传操作符。对于选择操作符,我们使用常规的锦标赛选择,锦标赛大小为 2;而对于交叉和变异操作符,我们选择之前定义的专用函数:
toolbox.register("select", tools.selTournament, tournsize=2) toolbox.register("mate", cxSubset) toolbox.register("mutate", mutSubset, indpb=1.0/SUBSET_SIZE) -
最后,是时候调用遗传算法流程,我们继续使用精英主义方法,其中 HOF 成员——当前最佳个体——始终不加修改地传递到下一代:
population, logbook = eaSimple( population, toolbox, cxpb=P_CROSSOVER, mutpb=P_MUTATION, ngen=MAX_GENERATIONS, stats=stats, halloffame=hof, verbose=True)
通过运行该算法 5 代,种群大小为 30,我们得到以下结果:
Initializing newsgroup data...
Number of features = 51280, train set size = 1192, test set size = 794
gen nevals max avg
0 200 0.639922 0.526988
1 166 0.639922 0.544121
2 174 0.663326 0.557525
3 173 0.669138 0.574895
...
198 170 0.852034 0.788416
199 172 0.852034 0.786208
200 167 0.852034 0.788501
-- Best Ever Fitness = 0.8520343720882079
-- Features subset selected =
1: 5074 = char_vectorizer__ crit
2: 12016 = char_vectorizer__=oo
3: 18081 = char_vectorizer__d usi
...
结果表明,我们成功识别出了一个包含 100 个特征的子集,f1 分数为 85.2%,这一结果与使用全部 51,280 个特征得到的 87.2%分数非常接近。
在查看显示最大适应度和平均适应度随代数变化的图表时,接下来的结果显示,如果我们延长进化过程,可能会获得进一步的改进:
图 11.3:程序搜索最佳特征子集的统计数据
进一步减少子集大小
如果我们希望进一步将子集大小减少到仅有 10 个特征呢?结果可能会让你惊讶。通过将SUBSET_SIZE常数调整为 10,我们依然取得了一个值得称赞的 f1 得分:76.1%。值得注意的是,当我们检查这 10 个选定的特征时,它们似乎是一些熟悉单词的片段。在我们的分类任务中,任务是区分专注于摩托车的新闻组和与汽车相关的帖子,这些特征开始展现出它们的相关性:
-- Features subset selected =
1: 16440 = char_vectorizer__car
2: 18813 = char_vectorizer__dod
3: 50905 = char_vectorizer__yamah
4: 18315 = char_vectorizer__dar
5: 10373 = char_vectorizer__. The
6: 6586 = char_vectorizer__ mu
7: 4747 = char_vectorizer__ bik
8: 4439 = char_vectorizer__ als
9: 15260 = char_vectorizer__ave
10: 40719 = char_vectorizer__rcy
移除字符 n-gram
以上结果引发了一个问题:我们是否应该仅使用单词 n-gram,而去除字符 n-gram 呢?我们可以通过使用一个单一的向量化器来实现,具体方法如下:
newsgroup_classifier.py program are as follows:
正在初始化新闻组数据...
特征数量 = 2666,训练集大小 = 1192,测试集大小 = 794
使用所有特征的 f1 得分:0.8551359241014413
使用 100 个随机特征子集的 f1 得分:0.6333756056319708
These results suggest that exclusively using word n-grams can achieve comparable performance to the original approach while using a significantly smaller feature set (2,666 features).
If we now run the genetic algorithm again, the results are the following:
-- 最佳健身得分 = 0.750101164515984
-- 选定的特征子集 =
1: 1669 = 换油
2: 472 = 汽车
3: 459 = 汽车
4: 361 = 自行车
5: 725 = 检测器
6: 303 = 汽车
7: 296 = 自动
8: 998 = 福特
9: 2429 = 丰田
10: 2510 = v6
This set of selected features makes a lot of sense within the context of our classification task and provides insights into how the classifier operates.
Summary
In this chapter, we delved into the rapidly evolving field of NLP. We began by exploring word embeddings and their diverse applications. Our journey led us to experiment with solving the mystery-word game using genetic algorithms, where word embedding vectors served as the genetic chromosome. Following this, we ventured into n-grams and their role in document classification through a newsgroup message classifier. In this context, we harnessed the power of genetic algorithms to identify a compact yet effective subset of n-gram features derived from the dataset.
Finally, we endeavored to minimize the feature subset, aiming to gain insights into the classifier’s operations and interpret the factors influencing its predictions. In the next chapter, we will delve deeper into the realm of explainable and interpretable AI while applying genetic algorithms.
Further reading
For more information on the topics that were covered in this chapter, please refer to the following resources:
* *Hands-On Python Natural Language Processing* by *Aman Kedia* and *Mayank Rasu*, *June* *26, 2020*
* *Semantle* word game: [`semantle.com/`](https://semantle.com/)
* **scikit-learn** 20 newsgroups dataset: [`scikit-learn.org/stable/modules/generated/sklearn.datasets.fetch_20newsgroups.html`](https://scikit-learn.org/stable/modules/generated/sklearn.datasets.fetch_20newsgroups.html)
* **scikit-learn** **TfidfVectorizer**: [`scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.TfidfVectorizer.html`](https://scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.TfidfVectorizer.html)
第十二章:可解释人工智能、因果关系与基因算法中的反事实
本章探讨了基因算法在生成“假设”场景中的应用,提供了对数据集和相关机器学习模型分析的宝贵见解,并为实现预期结果提供了可操作的洞察。
本章开始时介绍了 可解释人工智能(XAI)和 因果关系 领域,随后解释了 反事实 的概念。我们将使用这一技术探索无处不在的 德国信用风险 数据集,并利用基因算法对其进行反事实分析,从中发现有价值的洞察。
到本章结束时,你将能够做到以下几点:
-
熟悉 XAI 和因果关系领域及其应用
-
理解反事实的概念及其重要性
-
熟悉德国信用风险数据集及其缺点
-
实现一个应用程序,创建反事实的“假设”场景,为这个数据集提供可操作的洞察,并揭示其相关机器学习模型的操作。
本章将从对 XAI 和因果关系的简要概述开始。如果你是经验丰富的数据科学家,可以跳过这一介绍部分。
技术要求
在本章中,我们将使用 Python 3 和以下支持库:
-
deap
-
numpy
-
pandas
-
scikit-learn
重要提示
如果你使用的是我们提供的 requirements.txt 文件(见 第三章),这些库已经包含在你的环境中。
本章中将使用的程序可以在本书的 GitHub 仓库中找到,网址是 github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/tree/main/chapter_12。
查看以下视频,观看代码演示:
解锁黑盒 – XAI
XAI 是 人工智能(AI)领域的一个关键元素,旨在揭示机器学习模型复杂的工作原理。随着人工智能应用的不断增长,理解模型的决策过程变得至关重要,以建立信任并确保负责任的部署。
XAI 旨在解决此类模型固有的复杂性和不透明性问题,并提供清晰且易于理解的预测解释。这种透明性不仅增强了 AI 模型的可解释性,还使用户、利益相关者和监管机构能够审视和理解这些过程。在医疗保健和金融等关键领域,决策具有现实世界的后果,XAI 显得尤为重要。例如,在医学诊断中,可解释的模型不仅提供准确的预测,还揭示了影响诊断的医学图像或患者记录中的具体特征,从而建立信任并符合伦理标准。
实现可解释性的有效方法之一是通过模型无关技术。这些方法为任何机器学习模型提供事后(“事后解释”)的解释,不论其架构如何。像SHAP 值和LIME等技术,通过对输入数据或模型参数进行小幅、受控的调整,生成解释,从而揭示对预测贡献最大的特征。
在 XAI 的基础上,因果关系通过探索“是什么背后的‘为什么’”为模型提供了更深层次的解释,正如下节所述。
揭示因果关系——AI 中的因果性
不仅仅是了解 AI 的预测结果,还要理解这些预测背后的因果关系,这在决策具有重大影响的领域尤为重要。
在 AI 中,因果性探索数据的各个方面的变化是否会影响模型的预测或决策。例如,在医疗保健领域,了解患者参数与预测结果之间的因果关系有助于更有效地量身定制治疗方案。目标不仅是准确的预测,还要理解这些预测背后的机制,以便为数据提供更加细致且可操作的洞察。
假设情景——反事实
反事实通过探索“假如”情景并考虑替代结果,进一步增强了 AI 系统的可解释性。反事实解释帮助我们了解如何通过调整输入来影响模型预测,通过微调这些输入并观察模型决策中的变化(或没有变化)。这一过程本质上提出了“如果呢?”的问题,并使我们能够获得有关 AI 模型敏感性和鲁棒性的有价值的洞察。
例如,假设一个 AI 驱动的汽车决定避开行人。通过反事实分析,我们可以揭示在不同条件下这一决策如何发生变化,从而为模型的行为提供有价值的洞察。另一个例子是推荐系统。反事实分析可以帮助我们理解如何调整某些用户偏好,可能会改变推荐的项目,从而为用户提供更清晰的系统工作原理,并使开发者能够提高用户满意度。
除了提供对模型行为的更深理解外,反事实分析还可以用于模型改进和调试。通过探索替代场景并观察变化如何传播,开发人员可以发现潜在的弱点、偏差或优化空间。
正如我们在以下章节中所示,探索“假设”场景也可以使用户预测和解读 AI 系统的响应。
遗传算法在反事实分析中的应用——导航替代场景
遗传算法作为执行反事实分析的有用工具,提供了一种灵活的方式来修改模型输入,从而达到预期的结果。在这里,遗传算法中的每个解代表一个独特的输入组合。优化目标取决于模型的输出,并可以结合与输入值相关的条件,例如限制变化或最大化某个特定输入值。
在接下来的章节中,我们将利用遗传算法对一个机器学习模型进行反事实分析,该模型的任务是确定贷款申请人的信用风险。通过这种探索,我们旨在回答有关特定申请人的各种问题,深入了解模型的内在运作。此外,这一分析还可以提供可操作的信息,帮助申请人提高获得贷款的机会。
让我们首先熟悉将用于训练模型的数据集。
德国信用风险数据集
在本章的实验中,我们将使用经过修改的德国信用风险数据集,该数据集在机器学习和统计学领域的研究和基准测试中被广泛使用。原始数据集可以从UCI 机器学习库访问,包含 1,000 个实例,每个实例有 20 个属性。该数据集旨在进行二分类任务,目的是预测贷款申请人是否值得信贷或存在信用风险。
按照现代标准,数据集中某些原始属性被认为是受保护的,特别是代表候选人性别和年龄的属性。在我们修改后的版本中,这些属性已被排除。此外,其他一些特征要么被删除,要么其值已被转换为数值格式,以便简化处理。
修改后的数据集可以在chapter_12/data/credit_risk_data.csv文件中找到,包含以下列:
-
checking: 申请人支票账户的状态:
-
0: 没有支票账户
-
1: 余额 < 100 德国马克
-
2: 100 <= 余额 < 200 德国马克
-
3: 余额 >= 200 德国马克
-
-
duration: 申请贷款的时长(月数)
-
credit_history: 申请人的信用历史信息:
-
0: 没有贷款/所有贷款已按时偿还
-
1: 现有贷款已按时偿还
-
2: 本银行的所有贷款均已按时还清
-
3: 过去曾有还款延迟
-
4: 存在重要账户/其他贷款
-
-
金额: 申请贷款的金额
-
储蓄: 申请人的储蓄账户状态:
-
0: 未知/没有储蓄账户
-
1: 余额 < 100 德国马克
-
2: 100 <= 余额 < 500 德国马克
-
3: 500 <= 余额 < 1000 德国马克
-
4: 余额 >= 1000 德国马克
-
-
就业时长:
-
0: 失业
-
1: 时长 < 1 年
-
2: 1 <= 时长 < 4 年
-
3: 4 <= 时长 < 7 年
-
4: 时长 >= 7 年
-
-
其他债务人: 除主要申请人外,任何可能是共同债务人或共同承担贷款财务责任的个人:
-
无
-
担保人
-
共同申请人
-
-
现住址: 申请人在当前地址的居住时长,用 1 到 4 之间的整数表示
-
住房: 申请人的住房情况:
-
免费
-
自有
-
租赁
-
-
信用账户数: 在同一银行持有的信用账户数量
-
负担人: 申请人经济上依赖的人的数量
-
电话: 申请人是否有电话(1 = 是,0 = 否)
-
信用风险: 要预测的值:
-
1: 高风险(表示违约或信用问题的可能性较高)
-
0: 低风险
-
为了说明,我们提供了数据的前 10 行:
1,6,4,1169,0,4,none,4,own,2,1,1,1
2,48,1,5951,1,2,none,2,own,1,1,0,0
0,12,4,2096,1,3,none,3,own,1,2,0,1
1,42,1,7882,1,3,guarantor,4,for free,1,2,0,1
1,24,3,4870,1,2,none,4,for free,2,2,0,0
0,36,1,9055,0,2,none,4,for free,1,2,1,1
0,24,1,2835,3,4,none,4,own,1,1,0,1
2,36,1,6948,1,2,none,2,rent,1,1,1,1
0,12,1,3059,4,3,none,4,own,1,1,0,1
2,30,4,5234,1,0,none,2,own,2,1,0,0
虽然在以往的数据集工作中,我们的主要目标是开发一个机器学习模型,以对新数据做出精准的预测,但现在我们将使用反事实情境来扭转局面,并识别出与期望预测匹配的数据。
探索用于信用风险预测的反事实情境
从数据中可以看出,许多申请人被认为是信用风险(最后的值为1),导致贷款被拒。对于这些申请人,提出以下问题:他们能采取什么措施改变这一决定,并被视为有信用?(结果为0)。这里所说的措施是指更改他们的某些属性状态,例如他们申请的借款金额。
在检查数据集时,一些属性对于申请人来说是困难或甚至不可能改变的,比如就业时长、抚养人数或当前住房。对于我们的例子,我们将重点关注以下四个属性,它们都有一定的灵活性:
-
金额: 申请贷款的金额
-
时长: 申请贷款的时长(以月为单位)
-
支票账户: 申请人的支票账户状态
-
储蓄: 申请人的储蓄账户状态
现在问题可以这样表述:对于一个目前被标记为信用风险的候选人,我们可以对这四个属性(或其中一些)进行哪些最小的变化,使结果变为信用良好?
为了回答这个问题,以及其他相关问题,我们将创建以下内容:
-
一个已经在我们的数据集上训练的机器学习模型。该模型将被用来提供预测,从而在修改申请人数据时测试潜在的结果。
-
基于遗传算法的解决方案,寻找新的属性值以回答我们的问题。
这些组件将使用 Python 实现,如下文所述。
Applicant 类
Applicant类表示数据集中的一个申请人;换句话说,就是 CSV 文件中的一行数据。该类还允许我们修改amount、duration、checking和savings字段的值,这些字段代表了申请人的相应属性。此类可以在applicant.py文件中找到,文件位于github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_12/applicant.py。
该类的主要功能如下所示:
-
该类的init()方法使用dataset_row参数的值从对应的数据集行中复制值,并创建一个代表申请人的实例。
-
除了先前提到的四个属性的设置器和获取器外,**get_values()方法返回四个属性的当前值,而with_values()**方法则创建原始申请人实例的副本,并随之修改这四个属性的复制值。这两个方法都使用整数列表来表示四个属性的值,因为它们将被遗传算法直接使用,遗传算法将潜在的申请人表示为四个整数的列表。
CreditRiskData 类
CreditRiskData类封装了信用风险数据集及其上训练的机器学习模型。该类位于credit_risk_data.py文件中,可以在github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_12/credit_risk_data.py找到。
该类的主要功能在以下步骤中体现:
-
该类的**init()方法初始化随机种子;然后调用read_dataset()**方法,该方法从 CSV 文件中读取数据集:
self.randomSeed = randomSeed self.dataset = self.read_dataset() -
接下来,它检查是否已创建并保存了训练好的模型文件。如果模型文件存在,则加载它。否则,调用**train_model()**方法。
-
**train_model()**方法创建了一个随机森林分类器,该分类器首先通过 5 折交叉验证程序进行评估,以验证其泛化能力:
classifier = RandomForestClassifier( random_state=self.randomSeed) kfold = model_selection.KFold(n_splits=NUM_FOLDS) cv_results = model_selection.cross_val_score( classifier, X, y, cv=kfold, scoring='accuracy') print(f"Model's Mean k-fold accuracy = {cv_results.mean()}") -
接下来,使用整个数据集训练模型并对其进行评估:
classifier.fit(X, y) y_pred = classifier.predict(X) print(f"Model's Training Accuracy = {accuracy_score(y, y_pred)}") -
一旦训练完成,随机森林 模型可以为数据集的各个属性分配 特征重要性 值,表示每个属性对模型预测的贡献。虽然这些值提供了对影响模型决策的因素的见解,但我们将在这里使用它们来验证我们假设的四个属性是否能产生不同的结果:
feature_importances = dict(zip(X.columns, classifier.feature_importances_)) print(dict(sorted(feature_importances.items(), key=lambda item: -item[1]))) -
is_credit_risk() 方法利用训练过的模型,通过 Scikit-learn 的 predict() 方法预测给定申请者数据的结果,当候选者被认为是信用风险时返回 True。
-
此外,risk_probability() 方法提供一个介于 0 和 1 之间的浮动值,表示申请者被认为是信用风险的程度。它利用模型的 predict_proba() 方法,在应用阈值将其转换为离散值 0 或 1 之前,捕获连续的输出值。
-
方便的方法 get_applicant() 允许我们从数据集中选择一个申请者的行并打印其数据。
-
最后,main() 函数首先通过创建 CreditRiskData 类的实例来启动,如果需要,它会第一次训练模型。接着,它从数据集中获取第 25 个申请者的信息并打印出来。之后,它修改四个可变属性的值,并打印修改后的申请者信息。
-
当第一次执行 main() 函数时,交叉验证测试评估的结果,以及训练精度,将会被打印出来:
Loading the dataset... Model's Mean k-fold accuracy = 0.7620000000000001 Model's Training Accuracy = 1.0这些结果表明,虽然训练过的模型能够完全再现数据集的结果,但在对未见过的样本进行预测时,模型的准确率约为 76%——对于这个数据集来说,这是一个合理的结果。
-
接下来,特征重要性值按降序打印。值得注意的是,列表中的前几个属性就是我们选择修改的四个:
------- Feature Importance values: { "amount": 0.2357488244229738, "duration": 0.15326057481242433, "checking": 0.1323559111404014, "employment_duration": 0.08332785367394725, "credit_history": 0.07824885834794511, "savings": 0.06956484835261427, "present_residence": 0.06271797270697153, … } -
数据集中第 25 行申请者的属性信息和预测结果现在被打印出来。值得注意的是,在文件中,这对应于第 27 行,因为第一行包含标题,数据行从 0 开始计数:
applicant = credit_data.get_applicant(25)输出结果如下:
Before modifications: ------------- Applicant 25: checking 1 duration 6 credit_history 1 amount 1374 savings 1 employment_duration 2 present_residence 2 … => Credit risk = True如输出所示,该申请者被认为是信用风险。
-
程序现在通过 with_values() 方法修改所有四个值:
modified_applicant = applicant.with_values([1000, 20, 2, 0])然后,它重复打印,反映变化:
After modifications: ------------- Applicant 25: checking 2 duration 20 credit_history 1 amount 1000 savings 0 employment_duration 2 present_residence 2 … => Credit risk = False
正如前面的输出所示,当使用新值时,申请者不再被认为是信用风险。虽然这些修改的值是通过反复试验手动选择的,但现在是时候使用遗传算法来自动化这个过程了。
使用遗传算法进行反事实分析
为了演示遗传算法如何与反事实一起工作,我们将从第 25 行的相同申请者开始,该申请者最初被认为是信用风险,然后寻找使其预测信用可接受的最小变化集。如前所述,我们将考虑对amount、duration、checking和savings属性进行更改。
解的表示
在处理这个问题时,表示候选解的一种简单方法是使用一个包含四个整数值的列表,每个值对应我们希望修改的四个属性之一:
[amount, duration, checking, savings]
例如,我们在credit_risk_data.py程序的主函数中使用的修改值将表示如下:
[1000, 20, 2, 0]
正如我们在前几章中所做的那样,我们将利用浮点数来表示整数。这样,遗传算法可以使用行之有效的实数操作符进行交叉和变异,并且无论每个项的范围如何,都使用相同的表示。在评估之前,实数将通过int()函数转换为整数。
我们将在下一小节中评估每个解。
评估解
由于我们的目标是找到使预测结果反转的最小变化程度,因此一个问题出现了:我们如何衡量所做变化的程度?一种可能的方法是使用当前解的值与原始值之间的绝对差值之和,每个差值除以该值的范围,如下所示:
∑ i=1 4 |current valu e i − original valu e i| _______________________ range of valu e i
现在我们已经建立了候选解的表示和评估方法,我们准备展示遗传算法的 Python 实现。
遗传算法解
基于遗传算法的反事实搜索实现于名为01_counterfactual_search.py的 Python 程序中,该程序位于github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_12/01_counterfactual_search.py。
以下步骤描述了该程序的主要部分:
-
我们首先定义几个常量。然后,我们创建CreditRiskData类的一个实例:
credit_data = CreditRiskData(randomSeed=RANDOM_SEED) -
接下来的几个代码片段用于设置将用作解变量的四个属性的范围。我们首先声明占位符,如下所示:
bounds_low = [] bounds_high = [] ranges = []第一个列表包含四个属性的下限,第二个包含上限,第三个包含它们之间的差值。
-
接下来是**set_ranges()**方法,该方法接受四个属性的上下限,并相应地填充占位符。由于我们使用的是将转换为整数的实数,我们将增加每个范围的值,以确保结果整数的均匀分布:
bounds_low = [amount_low, duration_low, checking_low, savings_low] bounds_high = [amount_high, duration_high, checking_high, savings_high] bounds_high = [high + 1 for high in bounds_high] ranges = [high - low for high, low in zip(bounds_high, bounds_low)] -
然后,我们将使用**set_ranges()**方法为当前问题设置范围。我们选择了以下值:
-
amount:100..5000
-
duration:2..72
-
checking:0..3
-
savings:0..4:
bounds_low, bounds_high, ranges = set_ranges(100, 5000, 2, 72, 0, 3, 0, 4)
-
-
现在,我们必须从数据集的第 25 行选择申请人(与之前使用的一样),并将其原始的四个值保存在单独的变量applicant_values中:
applicant = credit_data.get_applicant(25) applicant_values = applicant.get_values() -
**get_score()**函数用于通过计算需要最小化的代价来评估每个解决方案的适应度。代价由两部分组成:首先,如评估解决方案部分所述,我们计算该解决方案表示的四个属性值与候选人匹配的原始值之间的距离——距离越大,代价越大:
cost = sum( [ abs(int(individual[i]) - applicant_values[i])/ranges[i] for i in range(NUM_OF_PARAMS) ] ) -
由于我们希望解决方案能够代表一个有信用的候选人,因此代价的第二部分(可选)用于惩罚被视为信用风险的解决方案。在这里,我们将使用**is_credit_risk()和risk_probability()**方法,这样当前者表明解决方案没有信用时,后者将用于确定增加的惩罚程度:
if credit_data.is_credit_risk( applicant.with_values(individual) ): cost += PENALTY * credit_data.risk_probability( applicant.with_values(individual)) -
该程序的其余部分与我们之前看到的非常相似,当时我们使用实数列表来表示个体——例如,第九章,深度学习网络架构优化。我们将开始使用单目标策略来最小化适应度,因为我们的目标是最小化由先前定义的代价函数计算出的值:
creator.create("FitnessMin", base.Fitness, weights=(-1.0,)) -
由于解决方案由四个浮动值的列表表示,每个值对应我们可以修改的一个属性,并且每个属性有其自己的范围,我们必须为它们定义独立的工具箱creator操作符,使用相应的bounds_low和bounds_high值:
toolbox.register("amount", random.uniform, \ bounds_low[0], bounds_high[0]) toolbox.register("duration", random.uniform, \ bounds_low[1], bounds_high[1]) toolbox.register("checking", random.uniform, \ bounds_low[2], bounds_high[2]) toolbox.register("savings", random.uniform, \ bounds_low[3], bounds_high[3]) -
这四个操作符接着在individualCreator的定义中使用:
toolbox.register("individualCreator", tools.initCycle, creator.Individual, (toolbox.amount, toolbox.duration, toolbox.checking, toolbox.savings), n=1) -
在将selection操作符分配给通常的tournament selection(锦标赛选择),并设置锦标赛大小为2后,我们将为其分配crossovers和mutation操作符,这些操作符专门用于有界浮点数列表染色体,并提供我们之前定义的范围:
toolbox.register("select", tools.selTournament, tournsize=2) toolbox.register("mate", tools.cxSimulatedBinaryBounded, low=bounds_low, up= bounds_high, eta=CROWDING_FACTOR) toolbox.register("mutate", tools.mutPolynomialBounded, low= bounds_low, up=bounds_high, eta=CROWDING_FACTOR, indpb=1.0 / NUM_OF_PARAMS) -
此外,我们将继续使用elitist方法,其中hall-of-fame(HOF)成员——当前最佳个体——始终不加修改地传递到下一代:
population, logbook = elitism.eaSimpleWithElitism( population, toolbox, cxpb=P_CROSSOVER, mutpb=P_MUTATION, ngen=MAX_GENERATIONS, stats=stats, halloffame=hof, verbose=True)
最后,我们打印出找到的最佳解决方案以及该解决方案的预测值。
现在是时候试用该程序并查看结果了!输出从打印所选申请人的原始属性和状态开始:
Loading the dataset...
Applicant 25:
checking 1
duration 6
credit_history 1
amount 1374
savings 1
employment_duration 2
present_residence 2
...
=> Credit risk = amount, duration, checking, and savings:
gen nevals min avg
0 50 0.450063 51.7213
1 42 0.450063 30.2695
2 44 0.393725 14.2223
3 37 0.38311 7.62647
...
28 40 0.141661 0.169646
29 40 0.141661 0.175401
30 44 0.141661 0.172197
-- 最佳方案:金额 = 1370,期限 = 16,检查 = 1,储蓄 = 1
-- 预测:是 _ 风险 = 检查和储蓄账户无需更改。
尽管金额被调整为1,374,它本来可以保持不变为 1374。通过直接调用is_credit_risk()函数,并使用值[1374, 16, 1, 1],可以验证这一点。根据我们的成本函数定义,1370 与 1374 之间的差异除以 4900 的范围是微小的,可能导致算法需要更多的世代才能识别出 1374 比 1370 更好。通过将金额的范围缩小到 1000..2000,同样的程序能够在规定的 30 代内很好地识别出 1374 的值。
更多的“假设”场景
我们发现,将申请人 25 的贷款期限从 6 个月调整为 16 个月,可以使得申请获得信用。但是,如果申请人希望更短的期限,或想最大化贷款金额呢?这些正是反事实所探索的“假设”场景,我们编写的代码可以模拟不同场景并解决这些问题,如下文小节所示。
减少期限
让我们从同一申请人希望将期限设置为比之前找到的 16 个月更短的情况开始——其他变化能否弥补这一点?
根据前次运行的经验,缩小四个属性的允许范围可能是有益的。让我们尝试采取更保守的方式,并使用以下范围:
-
金额: 1000..2000
-
期限: 2..12
-
检查: 0..1
-
储蓄: 0..1
在这里,我们将期限限制为 12 个月,并且力求避免增加当前检查或储蓄账户的余额。这可以通过修改set_ranges()的调用来实现,如下所示:
bounds_low, bounds_high, ranges = set_ranges(1000, 2000, 2, 12, 0, 1, 0, 1)
当我们运行修改后的程序时,结果如下:
-- Best solution: Amount = 1249, Duration = 12, checking = 1, savings = 1
-- Prediction: is_risk = False
这表明,如果申请人愿意稍微降低所请求的贷款金额,则可以实现 12 个月的缩短期限。
如果我们想进一步减少期限呢?比如将期限范围更改为 1..10。这将得到以下结果:
-- Best solution: Amount = 1003, Duration = 10, checking = 1, savings = 0
-- Prediction: is_risk = True
这表明,算法未能在这些范围内找到一个申请人是可信贷的解决方案。请注意,这并不一定意味着不存在这样的解决方案,但似乎不太可能。
即使我们回去并允许checking和savings账户的原始范围(0..3 和 0..4),如果期限限制在 10 个月或更少,仍然找不到解决方案。然而,允许金额低于 1,000 似乎能解决问题。让我们使用以下范围:
-
金额: 100..2000
-
期限: 2..10
-
checking: 0..1
-
储蓄: 0..1
在这里,我们得到了以下解决方案:
-- Best solution: Amount = 971, Duration = 10, checking = 1, savings = 0
-- Prediction: is_risk = False
这意味着,如果申请者将贷款金额减少到 971,则申请将按所需的 10 个月期限获得批准。
更令人惊讶的是,savings属性的值为 0,低于原始的 1。如你所记得,这个属性的值解释如下:
-
0: 未知/没有储蓄账户
-
1: 余额 < 100 DM
-
2: 100 <= 余额 < 500 DM
-
3: 500 <= 余额 < 1000 DM
-
4: 余额 >= 1000 DM
看起来,在申请贷款时没有储蓄是不利的。而且,如果我们尝试所有除 0 以外的可能值,将范围设置为 1..3,则没有找到解决方案。这表明,根据使用的模型,没有储蓄账户比拥有储蓄账户更为优越,即使储蓄余额较高。这可能是模型存在缺陷的表现,或者是数据集本身存在问题,例如数据偏见或不完整。这样的发现是反事实推理的一种使用场景。
最大化贷款金额
到目前为止,我们已经操作了一个最初被认为是信用风险的申请者的结果。然而,我们可以对任何申请者进行这种“假设”游戏,包括那些已经被批准的申请者。让我们考虑第 68 行的申请者(文件中的第 70 行)。当打印出申请者信息时,我们看到以下内容:
Applicant 68:
checking 0
duration 36
credit_history 1
amount 1819
savings 1
employment_duration 2
present_residence 4
...
=> Credit risk = 02_counterfactual_search.py, which is located at https://github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_12/02_counterfactual_search.py.
This program is identical to the previous one, except for three small changes. The first change is the use of this particular applicant:
applicant = credit_data.get_applicant(68)
The second change is to the range values:
bounds_low, bounds_high, ranges = set_ranges(2000, 50000, 36, 36, 0,
0, 1, 1)
The amount range is modified to allow up to a sum of 50,000, while the other ranges have been fixed to the existing values of the candidate. This will enable the genetic algorithm to only modify the amount.
But how do we instruct the genetic algorithm to *maximize* the loan amount? As you may recall, the cost function was initially designed to minimize the distance between the modified individual and the original one within the given range. However, in this scenario, we want the loan amount to be as large as possible compared to the original amount. One approach to address this is to replace the cost function with a new one. However, we’ll explore a somewhat simpler solution: we’ll set the original loan amount value to the same value we use for the upper end of the range, which is 50,000 in this case. By doing this, when the algorithm aims to find the closest possible solution, it will work inherently to maximize the amount toward this upper limit. This can be done by adding a single line of code that overrides the original amount value of the applicant. The line is placed immediately following the one that stores the original attribute values to be used by the cost function:
applicant_values = applicant.get_values()
applicant_values[0] 被使用,因为金额属性是程序所用的四个值中的第一个。
运行这个程序会得到以下输出:
-- Best solution: Amount = 14165, Duration = 36, checking = 0, savings = 1
-- Prediction: is_risk = False
上述输出表明,这位申请者在增加贷款金额的同时,保持了原有的信用良好状态,而无需对其他属性做出任何更改。
接下来的问题是,是否可以通过允许更改checking和/或savings属性,进一步增加贷款金额。为此,我们将修改边界,使这两个属性可以调整为任何有效值:
bounds_low, bounds_high, ranges = set_ranges(2000, 50000, 36, 36, 0,
3, 0, 4)
修改后的程序结果有些令人惊讶:
-- Best solution: Amount = 50000, Duration = 36, checking = 1, savings = 1
-- Prediction: is_risk = False
这个结果表明,将支票账户的状态从 0(没有支票账户)改为 1(余额<100 DM)就足以获得显著更高的贷款金额。如果我们用更高的金额(例如 500,000,替换程序中两个不同位置的值)重复这个实验,结果将类似——只要将支票状态从 0 改为 1,候选人就能获得这笔高额贷款。
这个观察结果同样适用于其他申请人,表明模型可能存在潜在的漏洞。
鼓励你对程序进行额外修改,探索自己的“如果…会怎样”场景。除了提供有价值的洞察和更深入地理解模型行为外,实验“如果…会怎样”场景还可以非常有趣!
扩展到其他数据集
本章演示的相同过程可以应用于其他数据集。例如,考虑一个用于预测租赁公寓预期价格的数据集。在这个场景中,你可以使用类似的反事实分析来确定房东可以对公寓进行哪些修改,以实现某个租金。使用类似本章介绍的程序,你可以应用遗传算法来探索输入特征变化对模型预测的敏感性,并确定可行的洞察,以实现期望的结果。
总结
在本章中,我们介绍了XAI、因果关系和反事实的概念。在熟悉了German Credis Risk 数据集后,我们创建了一个机器学习模型,用于预测申请人是否具备信用资格。接下来,我们应用了基于遗传算法的反事实分析,将该数据集应用于训练好的模型,探索了几个“如果…会怎样”场景,并获得了宝贵的洞察。
在接下来的两章中,我们将转向加速基于遗传算法的程序的执行,例如我们在本书中开发的程序,通过探索应用并发的不同策略。
进一步阅读
如需了解本章中涉及的更多内容,请参考以下资源:
-
Python 中的可解释 AI(XAI)实践,作者:Denis Rothman,2020 年 7 月
-
Python 中的因果推理与发现,作者:Aleksander Molak,2023 年 5 月
-
企业中的负责任 AI,作者:Adnan Masood,2023 年 7 月
-
Scikit-learn RandomForestClassifier:
scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestClassifier.html
第四部分:通过并发和云策略提升性能
本部分重点介绍通过先进的编程技术——特别是并发和云计算——提升遗传算法的性能。第一章介绍了并发,特别是多进程,作为提升遗传算法效率的工具。通过将多种多进程方法应用于一个计算密集型的 One-Max 问题版本,展示了显著的性能提升。接下来的一章则转向客户端-服务器模型,将遗传算法划分为异步客户端操作和服务器端适应度函数计算。该模型通过 Flask 实现服务器端,使用 Python 的 asyncio 实现客户端,最终部署到 AWS Lambda 云端。
本部分包含以下章节:
-
第十三章*,加速遗传算法:并发的力量*
-
第十四章*,超越本地资源:在云端扩展遗传算法*
第十三章:加速遗传算法——并发的力量
本章深入探讨了如何通过并发,特别是多进程,来提升遗传算法的性能。我们将探索 Python 内置的功能以及外部库来实现这一改进。
本章首先强调了将 并发 应用于遗传算法的潜在好处。接着,我们通过尝试各种 多进程 方法来解决计算密集型的 One-Max 问题,将这一理论付诸实践。这使我们能够衡量通过这些技术实现的性能提升程度。
本章结束时,你将能够做到以下几点:
-
了解为什么遗传算法可能计算密集且耗时
-
认识到为什么遗传算法非常适合并发执行
-
实现一个计算密集型的 One-Max 问题版本,我们之前已经探索过
-
学习如何使用 Python 内置的多进程模块加速遗传算法的过程
-
熟悉 SCOOP 库,学习如何将其与 DEAP 框架结合使用,进一步提高遗传算法的效率
-
尝试两种方法,深入了解如何将多进程应用于当前问题
技术要求
在本章中,我们将使用 Python 3 并配合以下支持库:
-
deap
-
numpy
-
scoop —— 本章介绍
重要提示
如果你使用我们提供的 requirements.txt 文件(见 第三章),这些库已经包含在你的环境中了。
本章中将使用的程序可以在本书的 GitHub 仓库中找到,链接如下:github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/tree/main/chapter_13。
查看以下视频,了解代码的实际运行:
实际应用中遗传算法的长时间运行
到目前为止,我们探讨的示例程序虽然解决了实际问题,但它们故意设计得可以迅速收敛到一个合理的解。然而,在实际应用中,由于遗传算法的工作方式——通过考虑多样化的潜在解决方案来探索解空间——它通常会非常耗时。影响典型遗传算法运行时间的主要因素如下:
-
世代数:遗传算法通过一系列世代来运行,每一代都涉及对种群的评估、选择和操作。
-
种群大小:遗传算法保持一个潜在解的种群;更复杂的问题通常需要更大的种群。这增加了每一代中需要评估、选择和操作的个体数量。
-
适应度评估:必须评估种群中每个个体的适应度,以确定其解决问题的效果。根据适应度函数的复杂性或优化问题的性质,评估过程可能既计算密集又耗时。
-
遗传操作:选择用于选择作为每代父母的个体对。交叉和变异应用于这些个体对,并且根据算法的设计,可能会计算密集,特别是在处理复杂数据结构时。然而,在实践中,适应度函数的持续时间通常是每个个体所消耗时间的主导因素。
减少遗传算法长时间运行的一个显而易见的方式是使用并行化,正如我们将在以下小节中进一步探讨的。
并行化遗传算法
在单一代际内,遗传算法可以被认为是显然可并行化的——它们可以轻松地分解为多个独立任务,这些任务之间几乎没有或完全没有依赖关系或交互。这是因为种群中个体的适应度评估和操作通常是独立的任务。每个个体的适应度是根据其自身特征评估的,而遗传操作符(交叉和变异)是独立地应用于每一对个体的。这种独立性使得这些任务能够轻松并行执行。
有两种并行化方法——多线程和多进程——是我们将在以下小节中探讨的内容。
多线程
多线程是一种并发执行模型,允许多个线程在同一进程内存在,共享相同的资源,如内存空间,但独立运行。每个线程代表一个独立的控制流,使程序能够并发执行多个任务。
在多线程环境中,线程可以被看作是共享相同地址空间的轻量级进程。多线程特别适用于可以分解为较小、独立工作单元的任务,使得可用资源的使用更加高效,并增强响应性。以下图示了这一点:
图 13.1:多个线程在单一进程内并发运行
然而,Python 中的多线程面临一些限制,这些限制影响了它在我们用例中的效果。一个主要因素是全局解释器锁(GIL),这是 CPython(Python 的标准实现)中的一个关键部分。GIL 是一个互斥锁(mutex),用于保护对 Python 对象的访问,防止多个本地线程同时执行 Python 字节码。因此,多线程的好处主要体现在 I/O 密集型任务中,正如我们将在下一章中探讨的那样。对于那些计算密集型任务,这些任务不经常释放 GIL,且在许多数值计算中比较常见,多线程可能无法提供预期的性能提升。
注意
Python 社区的讨论和持续的研究表明,GIL 带来的限制可能会在未来的 Python 版本中解除,从而提高多线程的效率。
幸运的是,接下来描述的方法是一个非常可行的选择。
多进程
多进程是一种并发计算范式,涉及计算机系统内多个进程的同时执行。与多线程不同,多进程允许创建独立的进程,每个进程都有独立的内存空间。这些进程可以在不同的 CPU 核心或处理器上并行运行,使其成为一种强大的并行化任务的技术,能够充分利用现代多核架构,如下图所示:
图 13.2:多个进程在独立的核心上同时运行
每个进程独立运行,避免了与共享内存模型相关的限制,例如 Python 中的全局解释器锁(GIL)。多进程对于 CPU 密集型任务尤其有效,这类任务在遗传算法中常见,其中计算工作负载可以被划分为可并行化的单元。
由于多进程似乎是一种提高遗传算法性能的可行方法,我们将在本章剩余部分探讨其实现,使用 OneMax 问题的新版本作为我们的基准。
回到 OneMax 问题
在第三章,《使用 DEAP 框架》中,我们使用了 OneMax 问题作为遗传算法的“Hello World”。简要回顾一下,目标是发现一个指定长度的二进制字符串,使其数字之和最大。例如,在处理一个长度为 5 的 OneMax 问题时,考虑到的候选解包括 10010(数字之和=2)和 01110(数字之和=3),最终的最优解是 11111(数字之和=5)。
在第三章中,我们使用了问题长度为 100、种群大小为 200、50 代的参数,而在这里我们将处理一个大幅缩小的版本,问题长度为 10、种群大小为 20,且只有 5 代。这一调整的原因很快就会显现出来。
一个基准程序
该 Python 程序的初始版本为01_one_max_start.py,可在github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_13/01_one_max_start.py找到。
该程序的主要功能概述如下:
-
候选解通过一个由 0 和 1 组成的整数列表来表示。
-
**oneMaxFitness()**函数通过对列表元素求和来计算适应度:
def oneMaxFitness(individual): return sum(individual), # return a tuple toolbox.register("evaluate", oneMaxFitness) -
对于遗传操作,我们采用锦标赛选择(锦标赛大小为 4)、单点交叉和翻转位变异。
-
采用了精英主义方法,利用**elitism.eaSimpleWithElitism()**函数。
-
程序的运行时间通过调用**time.time()函数来测量,包围着main()**函数的调用:
if __name__ == "__main__": start = time.time() main() end = time.time() print(f"Elapsed time = {(end - start):.2f} seconds")
运行该程序会产生以下输出:
gen nevals max avg
0 20 7 4.35
1 14 7 6.1
2 16 9 6.85
3 16 9 7.6
4 16 9 8.45
5 13 10 8.9
Best Individual = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
Elapsed time = 0.00 seconds
输出表明程序在第 5 代时达到了最优解 1111111111,运行时间不到 10 毫秒(仅考虑经过时间的小数点后两位)。
另一个值得注意的细节,这将在后续实验中起作用,是每代进行的适应度评估次数。相关值可以在从左数第二列nevals中找到。尽管种群大小为 20,每代的评估次数通常少于 20 次。这是因为如果某个个体的适应度已被计算过,算法会跳过该适应度函数。将这一列的数值加起来,我们可以发现,在程序运行过程中执行的总适应度评估次数为 95 次。
模拟计算密集度
如前所述,遗传算法中最消耗计算资源的任务通常是对个体的适应度评估。为了模拟这一方面,我们将故意延长适应度函数的执行时间。
该修改已在 Python 程序02_one_max_busy.py中实现,程序可在github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_13/02_one_max_busy.py找到。
该程序基于之前的版本,进行了如下修改:
-
添加了一个**busy_wait()**函数。该函数通过执行一个空循环来消耗指定时长(以秒为单位):
def busy_wait(duration): current_time = time.time() while (time.time() < current_time + duration): pass -
更新原始适应度函数,以便在计算数字之和之前调用**busy_wait()**函数:
def oneMaxFitness(individual): busy_wait(DELAY_SECONDS) return sum(individual), # return a tuple -
添加了DELAY_SECONDS常量,并将其值设置为 3:
DELAY_SECONDS = 3
运行修改后的程序会产生以下输出:
gen nevals max avg
0 20 7 4.35
1 14 7 6.1
2 16 9 6.85
3 16 9 7.6
4 16 9 8.45
5 13 10 8.9
Best Individual = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
Elapsed time = 285.01 seconds
正如预期的那样,修改后的程序输出与原始程序相同,唯一显著的不同是经过的时间显著增加,约为 285 秒。
这个持续时间是完全合理的;正如前面部分所强调的那样,在程序的执行过程中有 95 次适应度函数的调用(nevals列中的值之和)。由于每次执行现在需要额外的 3 秒,因此预期的额外时间为 95 次乘以 3 秒,总计 285 秒。
在检查这些结果时,让我们也确定一下理论上的限制,或者我们可以追求的最佳情况。如输出所示,执行基准遗传算法涉及六个“轮次”的适应度计算——一次用于初始代(“代零”),另外五次用于随后的五代。在每代内完全并发的情况下,最佳的执行时间为 3 秒,即单次适应度评估的时间。因此,理论上我们可以达到的最佳结果是 18 秒,即 6 次乘以每轮 3 秒。
以这个理论上的限制为基础,我们现在可以继续探索将多进程应用到基准测试中的方法。
使用 Pool 类进行多进程
在 Python 中,multiprocessing.Pool模块提供了一种方便的机制,可以将操作并行化到多个进程中。通过Pool类,可以创建一组工作进程,并将任务分配给这些进程。
Pool类通过提供map和apply方法来抽象化管理单个进程的细节。相反,DEAP 框架使得利用这种抽象变得非常简单。toolbox模块中指定的所有操作都通过默认的map函数在内部执行。将这个map替换为Pool类中的map意味着这些操作,包括适应度评估,现在将分配到池中的工作进程上。
让我们通过将多进程应用到之前的程序来进行说明。此修改在03_one_max_pool.py Python 程序中实现,可以在github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_13/03_one_max_pool.py找到。
只需进行少量修改,具体如下:
-
导入multiprocessing模块:
import multiprocessing -
multiprocessing.Pool类实例的map方法被注册为 DEAP 工具箱模块中使用的map函数:
toolbox.register("map", pool.map) -
遗传算法流程,实现在main()函数中,现在在with语句下运行,该语句管理multiprocessing.Pool实例的创建和清理:
def main(): with multiprocessing.Pool() as pool: toolbox.register("map", pool.map) # create initial population (generation 0): population = toolbox.populationCreator( n=POPULATION_SIZE) ...
在四核计算机上运行修改后的程序,输出结果如下:
gen nevals max avg
0 20 7 4.35
1 14 7 6.1
2 16 9 6.85
3 16 9 7.6
4 16 9 8.45
5 13 10 8.9
Best Individual = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
Elapsed time = 78.49 seconds
如预期的那样,输出结果与原始程序相同,而运行时间明显比之前短。
重要提示
该程序的运行时间可能会因不同计算机之间的差异而有所不同,甚至在同一台机器上的连续运行之间也会有所变化。如前所述,此基准测试的最佳结果大约是 18 秒。如果您的计算机已经接近这个理论极限,您可以通过将种群大小加倍(或根据需要更多)来使基准程序变得更加 CPU 密集型。记得调整本章和下一章中的所有基准程序版本,以反映新的种群大小。
假设使用四核计算机,您可能期望运行时间是之前的四分之一。然而,在这种情况下,我们可以看到持续时间的比率大约是 3.6(≈285/79),低于预期的 4。
有几个因素导致我们没有完全实现节省时间的潜力。其中一个重要因素是与并行化过程相关的开销,在将任务分配给多个进程并协调它们的执行时,会引入额外的计算负担。
此外,任务的粒度也起着作用。虽然适应度函数消耗了大部分处理时间,但像交叉和变异等遗传操作可能会遇到并行化开销大于收益的情况。
此外,算法中的某些部分,如处理名人堂和计算统计数据,并没有并行化。这一限制限制了并行化可以发挥的程度。
为了说明最后一点,我们来看一下程序运行时,Mac 上 Activity Monitor 应用程序的快照:
图 13.3:Activity Monitor 展示四个遗传算法进程在运行
正如预期的那样,处理多处理器程序的四个 Python 进程得到了大量利用,尽管还没有达到 100%。这引出了一个问题,能否“榨取”更多 CPU 的潜力,进一步缩短程序的运行时间?在接下来的部分中,我们将探讨这一可能性。
增加进程数
由于我们手头的四个 CPU 并未被充分利用,问题随之而来:是否可以通过使用超过四个并发进程来进一步提高利用率?
当我们通过调用 multiprocessing.Pool() 创建 Pool 类的实例时,如果没有任何参数,默认创建的进程数将与可用 CPU 的数量相同——在我们的案例中是四个。然而,我们可以使用可选的 processes 参数来设置所需的进程数量,如下所示:
multiprocessing.Pool(processes=<number of processes>)
在我们的下一个实验中,我们将利用这个选项来改变进程数量,并比较结果时长。这将在 Python 程序 04_one_max_pool_loop.py 中实现,程序可通过 github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_13/04_one_max_pool_loop.py 获取。
这个程序对前一个程序做了一些修改,具体如下:
-
main() 函数被重命名为 run(),因为我们将多次调用它。它现在接受一个参数,num_processes。
-
Pool 对象的实例化会传递此参数,以创建所请求大小的进程池:
with multiprocessing.Pool(processes=num_processes) as pool: -
plot_graph() 函数被添加用来帮助展示结果。
-
程序的启动代码位于文件底部,现在创建了一个循环,多次调用 run() 函数,num_processes 参数从 1 增加到 20。它还将结果时长收集到列表 run_times 中:
run_times = [] for num_processes in range(1, 21): start = time.time() run(num_processes) end = time.time() run_time = end – start run_times.append(run_time) -
在循环结束时,利用 run_times 列表中的值绘制两个图表,借助 plot_graph() 函数:
plot_graph(1, run_times, "Number of Processes", "Run Time (seconds)", hr=33) plot_graph(2, [1/rt for rt in run_times], "Number of Processes", "(1 / Run Time)", "orange")
在我们继续描述这个实验的结果之前,请记住,实际的数值可能因不同的计算机而有所不同。因此,您的具体结果可能会有所不同。然而,主要的观察结果应该是成立的。
在我们的四核计算机上运行此程序将产生以下输出:
num_processes = 1
gen nevals max avg
0 20 7 4.35
1 14 7 6.1
2 16 9 6.85
3 16 9 7.6
4 16 9 8.45
5 13 10 8.9
Best Individual = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
Number of Processes = 1 => Run time = 286.62 seconds
num_processes = 2
gen nevals max avg
0 20 7 4.35
1 14 7 6.1
2 16 9 6.85
3 16 9 7.6
4 16 9 8.45
5 13 10 8.9
Best Individual = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
Number of Processes = 2 => Run time = 151.75 seconds
...
num_processes = 20
gen nevals max avg
0 20 7 4.35
1 14 7 6.1
2 16 9 6.85
3 16 9 7.6
4 16 9 8.45
5 13 10 8.9
Best Individual = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
Number of Processes = 20 => Run time = 33.30 seconds
此外,还生成了两个图表。第一个图表,如下图所示,展示了不同进程数量下程序的运行时间。正如预期的那样,随着进程数量的增加,运行时间持续减少,超出了四个可用 CPU 的容量。值得注意的是,超过八个进程后,性能提升变得非常有限:
图 13.4:程序在不同进程数量下的运行时长
图中虚线红线表示我们测试中取得的最短时长——约 31 秒。为了进行对比,我们回顾一下本次测试的理论极限:在每轮 3 秒的适应度计算中,6 轮的计算总时间最好的结果是 18 秒。
第二个图表,如下图所示,描绘了时长的倒数(即 1/时长),表示程序在不同进程数量下的“速度”:
图 13.5:程序在不同进程数下的运行时长
这张图表明,程序的速度随着进程数增加到 8 个时几乎呈线性增长,但超过这个点后,增长速率减缓。值得注意的是,图表显示在从 15 个进程增加到 16 个进程时,性能显著提升,这一趋势在之前的图表中也有所体现。
当进程数超过可用的物理 CPU 核心数时,所观察到的性能提升现象,称为“过度订阅”,可以与多种因素相关。这些因素包括任务重叠、I/O 和等待时间、多线程、超线程以及操作系统的优化。从 15 个进程到 16 个进程的显著性能提升可能受计算机硬件架构和操作系统进程调度策略的影响。此外,程序工作负载的特定结构,也由三分之二的适应度计算轮次中涉及正好 16 次适应度评估(如nevals列所示)可见,也可能有助于这种增加。需要注意的是,这些效果会因计算机架构和所解决问题的性质而有所不同。
这个实验的主要收获是,实验不同的进程数以找到程序的最佳配置非常重要。幸运的是,你不需要每次都重新运行整个遗传算法——几代就足够用来比较并找出最佳设置。
使用 SCOOP 库进行多进程处理
另一种引入多进程的方法是使用SCOOP,这是一个旨在将代码执行并行化和分布到多个进程的 Python 库。SCOOP,即Python 中的简单并发操作,为 Python 中的并行计算提供了一个简单的接口,我们稍后会详细探讨。
将 SCOOP 应用到基于 DEAP 的程序中与使用multiprocessing.Pool模块非常相似,如 Python 程序05_one_max_scoop.py所示,可以在github.com/PacktPublishing/Hands-On-Genetic-Algorithms-with-Python-Second-Edition/blob/main/chapter_13/05_one_max_scoop.py查看。
这个程序只需要对原始的非多进程版本02_one_max_busy.py进行几个修改;这些修改在此列出:
-
导入 SCOOP 的futures模块:
from scoop import futures -
将 SCOOP 的futures模块的map方法注册为 DEAP 工具箱模块中使用的“map”函数:
toolbox.register("map", futures.map)
就这样!但是,启动这个程序需要通过以下命令使用 SCOOP 作为主模块:
python3 -m scoop 05_one_max_scoop.py
在同一台四核计算机上运行该程序,得到如下输出:
SCOOP 0.7 2.0 on darwin using Python 3.11.1
Deploying 4 worker(s) over 1 host(s).
Worker distribution:
127.0.0.1: 3 + origin
Launching 4 worker(s) using /bin/zsh.
gen nevals max avg
0 20 7 4.35
1 14 7 6.1
2 16 9 6.85
3 16 9 7.6
4 16 9 8.45
5 13 10 8.9
Best Individual = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
03_one_max_pool.py program, as both programs employed four concurrent processes.
However, we have seen that “oversubscription” (i.e., using more concurrent processes than the number of available cores) could yield better results. Luckily, SCOOP enables us to control the number of processes, or “workers,” via a command-line argument. Let’s run the program again but, this time, use 16 workers:
python3 -m scoop -n 16 05_one_max_scoop.py
The resulting output is as follows:
SCOOP 0.7 2.0 在 darwin 上使用 Python 3.11.1
在 1 台主机上部署 16 个工作进程。
工作进程分配:
127.0.0.1: 15 + 原点
使用 /bin/zsh 启动 16 个工作进程。
gen nevals max avg
0 20 7 4.35
1 14 7 6.1
2 16 9 6.85
3 16 9 7.6
4 16 9 8.45
5 13 10 8.9
最佳个体 = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
忘记了未来的进度或接收到了意外的未来。这些警告表示通信问题,通常与由于过度订阅而导致的资源限制有关。尽管有这些警告,SCOOP 通常能够恢复并成功地重现预期的结果。
一些实验表明,当使用 20 个以上进程时,SCOOP 可以在 20 秒内完成任务,相较于我们在相同问题上使用 multiprocessing.Pool 模块时达到的 31 秒,取得了显著的提升。
这个改进可能源自于 SCOOP 在并行化方面的独特方法。例如,其动态任务分配可能比 multiprocessing.Pool 使用的静态方法更有效。此外,SCOOP 可能在进程管理开销上表现得更高效,且在可用核心上调度任务的能力更强。然而,这并不意味着 SCOOP 总是会优于 multiprocessing.Pool。建议试用两种方法,看看它们在你特定算法和问题上的表现。好消息是,在两者之间切换相对简单。
话虽如此,值得一提的是,SCOOP 提供了一个使其与 multiprocessing.Pool 区别开来的关键特性——分布式计算。这一特性允许在多台机器上进行并行处理。我们将在接下来的部分简要探讨这一功能。
使用 SCOOP 进行分布式计算
SCOOP 不仅支持单机上的多进程计算,还能够在多个互联的节点之间进行分布式计算。这一功能可以通过两种方式进行配置:
-
使用 --hostfile 参数:此参数后面应跟随一个包含主机列表的文件名。该文件中每行的格式为**<主机名或 IP 地址> <进程数>**,其中每一行指定了一个主机及该主机上要运行的进程数。
-
使用 --hosts 参数:此选项需要一个主机名列表。每个主机名应根据你打算在该主机上运行的进程数量列出。
如需更详细的信息和实际示例,建议查阅 SCOOP 的官方文档。
下一章将探讨一种超越单机限制的不同方法。
总结
在本章中,我们介绍了通过多进程将并发应用于遗传算法的概念,这是一种缓解其计算密集型特征的自然策略。展示了两种主要方法——使用 Python 内建的 multiprocessing.Pool 类和 SCOOP 库。我们采用了一个 CPU 密集型的经典 One-Max 问题作为基准,从中获得了一些洞见。本章的最后部分讨论了使用 SCOOP 库进行分布式计算的潜力。
在下一章,我们将通过采用客户端-服务器模型,将并发的概念提升到一个新的层次。这种方法将结合使用多进程和多线程,最终利用云计算的力量进一步提升性能。
深入阅读
要了解本章中涉及的更多内容,请参考以下资源:
-
《高级 Python 编程:使用经过验证的设计模式构建高性能、并发和多线程的 Python 应用程序》,作者:Dr. Gabriele Lanaro、Quan Nguyen 和 Sakis Kasampalis,2019 年 2 月
-
SCOOP 框架文档:
scoop.readthedocs.io/en/latest/ -
Python 多进程模块文档:
docs.python.org/3/library/multiprocessing.html