机器学习基础设施最佳实践-二-

69 阅读1小时+

机器学习基础设施最佳实践(二)

原文:annas-archive.org/md5/f3dcd9d910b2341a99f14dab450d4e9b

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:数值和图像数据的特征工程

在大多数情况下,当我们设计大规模机器学习系统时,我们得到的数据类型需要比仅仅可视化更多的处理。这种可视化仅用于机器学习系统的设计和开发。在部署期间,我们可以监控数据,正如我们在前几章中讨论的那样,但我们需要确保我们使用优化的数据来进行推理。

因此,在本章中,我们将专注于特征工程——找到描述我们的数据更接近问题域而不是数据本身的正确特征。特征工程是一个从原始数据中提取和转换变量的过程,以便我们可以使用它们进行预测、分类和其他机器学习任务。特征工程的目标是分析和准备数据,以便用于不同的机器学习任务,如预测或分类。

在本章中,我们将专注于数值和图像数据的特征工程过程。我们将从介绍典型的方法开始,例如我们之前用于可视化的主成分分析PCA)。然后,我们将介绍更高级的方法,例如t-学生分布随机网络嵌入t-SNE)和独立成分分析ICA)。最终,我们将使用自编码器作为数值和图像数据的降维技术。

在本章中,我们将涵盖以下主题:

  • 特征工程过程的基本原理

  • PCA 和类似方法

  • 用于数值和图像数据的自编码器

特征工程

特征工程是将原始数据转换为可用于机器学习算法的数值的过程。例如,我们可以将有关软件缺陷的原始数据(例如,它们的描述、它们所属模块的特征等)转换成我们可以用于机器学习的数值表。正如我们在上一章中看到的,原始数值是我们对作为数据来源的实体进行量化的结果。它们是应用测量仪器到数据的结果。因此,根据定义,它们更接近问题域而不是解决方案域。

另一方面,特征量化了原始数据,并且只包含对当前机器学习任务重要的信息。我们使用这些特征来确保我们在训练期间找到数据中的模式,然后可以在部署期间使用这些模式。如果我们从测量理论的角度来看这个过程,这个过程改变了数据的抽象级别。如果我们从统计学的角度来看这个过程,这是一个去除噪声和降低数据维度的过程。

在本章中,我们将重点关注使用诸如自动编码器等高级方法来降低数据维度和去噪图像数据的过程。

图 7*.1* 展示了特征提取在典型机器学习流程中的位置。这个流程在第二章中介绍过:

图 7.1 – 典型机器学习流程中的特征工程

图 7.1 – 典型机器学习流程中的特征工程

此图显示特征尽可能接近干净和验证过的数据,因此我们需要依赖前几章中的技术来可视化数据并减少噪声。特征工程之后的下一个活动是建模数据,如图 7*.2* 所示。此图展示了整个流程的某种简化视图。这也在第二章中介绍过:

图 7.2 – 典型机器学习流程。来自第二章的某种简化视图

图 7.2 – 典型机器学习流程。来自第二章的某种简化视图

我们之前已经讨论过建模,所以让我们更深入地探讨特征提取过程。由于数值数据和图像数据从这个角度来看有些相似,所以我们将在本章一起讨论它们。文本数据是不同的,因此我们将在下一章中专门讨论它。

然而,本章我的第一个最佳实践与特征提取和模型之间的联系相关。

最佳实践 #39

如果数据复杂但任务简单,例如创建一个分类模型,请使用特征工程技术。

如果数据复杂且任务复杂,尝试使用复杂但功能强大的模型,例如本书后面介绍的变压器模型。这类任务的例子可以是当模型完成了一个程序员开始编写的程序的一部分时进行代码补全。简化复杂数据以适应更简单的模型,可以使我们增加训练模型的可解释性,因为我们作为人工智能工程师,在数据整理过程中更加参与。

数值数据的特征工程

我们将通过使用之前用于可视化数据的技术来介绍数值数据的特征工程 – 主成分分析(PCA)。

PCA

PCA 用于将一组变量转换成相互独立的部分。第一个部分应该解释数据的变异性或与大多数变量相关。图 7*.3* 说明了这种转换:

图 7.3 – 从二维到二维的 PCA 变换的图形说明

图 7.3 – 从二维到二维的 PCA 变换的图形说明

这个图包含两个轴——蓝色的轴是原始坐标轴,橙色的轴是想象中的轴,为主成分提供坐标。转换不会改变xy轴的值,而是找到这样的转换,使得轴与数据点对齐。在这里,我们可以看到转换后的Y轴比原始的Y轴更好地与数据点对齐。

现在,让我们执行一些代码,这些代码可以读取数据并执行这种 PCA 转换。在这个例子中,数据有六个维度——也就是说,六个变量:

# read the file with data using openpyxl
import pandas as pd
# we read the data from the excel file,
# which is the defect data from the ant 1.3 system
dfDataAnt13 = pd.read_excel('./chapter_6_dataset_numerical.
              xlsx',sheet_name='ant_1_3', index_col=0)
dfDataAnt13

上述代码片段读取数据并显示它有六个维度。现在,让我们创建 PCA 转换。首先,我们必须从我们的数据集中移除依赖变量Defect

# let's remove the defect column, as this is the one that
# we could potentially predict
dfDataAnt13Pred = dfDataAnt13.drop(['Defect'], axis = 1)

然后,我们必须导入 PCA 转换并执行它。我们希望从五个变量(减去Defect变量后的六个变量)转换到三个维度。维度的数量完全是任意的,但因为我们之前章节中使用了两个维度,所以这次让我们使用更多:

# now, let's import PCA and find a few components
from sklearn.decomposition import PCA
# previously, we used 2 components, now, let's go with
# three
pca = PCA(n_components=3)
# now, the transformation to the new components
dfDataAnt13PCA = pca.fit_transform(dfDataAnt13Pred)
# and printing the resulting array
# or at least the three first elements
dfDataAnt13PCA[:3]

结果的 DataFrame——dfDataAnt13PCA——包含了转换后变量的值。它们尽可能独立于彼此(线性独立)。

我想强调一下我们如何处理这类数据转换的一般方案,因为这是一种相对标准的做事方式。

首先,我们实例化转换模块并提供参数。在大多数情况下,参数很多,但有一个参数n_components,它描述了我们希望有多少个组件。

第二,我们使用fit_transform()函数来训练分类器并将其转换成这些组件。我们使用这两个操作一起,仅仅是因为这些转换是针对特定数据的。没有必要在一个数据上训练转换,然后应用到另一个数据上。

我们可以用 PCA 做的,而其他类型的转换做不到的是,检查每个组件解释了多少变异性——也就是说,组件与数据对齐得有多好。我们可以用以下代码来做这件事:

# and let's visualize that using the seaborn library
import seaborn as sns
sns.set(rc={"figure.figsize":(8, 8)})
sns.set_style("white")
sns.set_palette('rocket')
sns.barplot(x=['PC 1', 'PC 2', 'PC 3'], y=pca.explained_variance_ratio_)

这段代码片段产生了图 7**.4所示的图表:

图 7.4 – 主成分解释的变异性

图 7.4 – 主成分解释的变异性

这个图显示第一个组件是最重要的——也就是说,它解释了最大的变异性。这种变异性可以看作是数据包含的信息量。在这个数据集的例子中,第一个组件解释了大约 80%的变异性,第二个组件几乎解释了 20%。这意味着我们的数据集有一个主导维度,以及数据在第二个维度上的分散。第三个维度几乎不存在。

这就是我的下一个最佳实践所在。

最佳实践 #40

如果数据在某种程度上是线性可分的,并且处于相似的比例,请使用 PCA。

如果数据是线性的,或者多线性的,PCA 对于训练模型有很大的帮助。然而,如果数据不是线性的,请使用更复杂的模型,例如 t-SNE。

t-SNE

作为一种转换,PCA 在数据在某种程度上线性可分时工作得很好。在实践中,这意味着坐标系可以定位得使大部分数据位于其轴之一上。然而,并非所有数据都如此。一个这样的数据例子是可以被可视化为圆的数据 – 它在两个轴上均匀分布。

为了降低非线性数据的维度,我们可以使用另一种技术 – t-SNE。这种降维技术基于提取一个神经网络的激活值,该神经网络被训练以拟合输入数据。

以下代码片段创建了对数据进行 t-SNE 转换。它遵循了之前描述的 PCA 的相同架构,并且也将维度降低到三个:

# for t-SNE, we use the same data as we used previously
# i.e., the predictor dfDataAnt13Pred
from sklearn.manifold import TSNE
# we create the t-sne transformation with three components
# just like we did with the PCA
tsne = TSNE(n_components = 3)
# we fit and transform the data
dfDataAnt13TSNE = tsne.fit_transform(dfDataAnt13Pred)
# and print the three first rows
dfDataAnt13TSNE[:3]

生成的 DataFrame – dfDataAnt13TSNE – 包含了转换后的数据。不幸的是,t-SNE 转换不允许我们获取解释变异性的值,因为这种概念对于这种转换来说并不存在。然而,我们可以可视化它。以下图展示了三个成分的 3D 投影:

图 7.5 – t-SNE 成分的可视化。绿色点代表无缺陷成分,红色点代表有缺陷的成分

图 7.5 – t-SNE 成分的可视化。绿色点代表无缺陷成分,红色点代表有缺陷的成分

这是我在本章中的下一个最佳实践。

最佳实践 #41

如果你对数据的属性不了解,并且数据集很大(超过 1,000 个数据点),请使用 t-SNE。

t-SNE 是一个非常好且稳健的转换。它特别适用于大型数据集 – 即那些包含数百个数据点的数据集。然而,一个挑战是,t-SNE 提供的成分没有解释。我们还应该知道,t-SNE 的最佳结果需要仔细调整超参数。

ICA

我们还可以使用另一种类型的转换 – ICA。这种转换以这种方式工作,即它找到最不相关的数据点并将它们分离。它在历史上被用于医疗领域,以从高频 脑电图EEG)信号中去除干扰和伪影。这种干扰的一个例子是 50 - Hz 的电力信号。

然而,它可以用于任何类型的数据。以下代码片段说明了如何使用 ICA 对我们在之前转换中使用过的相同数据集进行处理:

# we import the package
from sklearn.decomposition import FastICA
# instantiate the ICA
ica = FastICA(n_components=3)
# transform the data
dfDataAnt13ICA = ica.fit_transform(dfDataAnt13Pred)
# and check the first three rows
dfDataAnt13ICA[:3]

ICA 需要产生比原始数据更少的组件,尽管在前面的代码片段中我们只使用了三个。以下图示展示了这些组件的可视化:

图 7.6 – 使用 ICA 转换的数据集的可视化

图 7.6 – 使用 ICA 转换的数据集的可视化

图 7.6 中,绿色组件是没有缺陷的,而红色组件含有缺陷。

局部线性嵌入

一种介于 t-SNE 和 PCA(或 ICA)之间的技术被称为局部线性嵌入LLE)。这种技术假设相邻节点在某种虚拟平面上彼此靠近。算法以这种方式训练一个神经网络,即它保留了相邻节点之间的距离。

以下代码片段说明了如何使用 LLE 技术:

from sklearn.manifold import LocallyLinearEmbedding
# instantiate the classifier
lle = LocallyLinearEmbedding(n_components=3)
# transform the data
dfDataAnt13LLE = lle.fit_transform(dfDataAnt13Pred)
# print the three first rows
dfDataAnt13LLE[:3]

这个片段的结果与之前算法的 DataFrame 相似。以下是可视化:

图 7.7 – LLE 组件的可视化

图 7.7 – LLE 组件的可视化

我们迄今为止讨论的所有技术都是灵活的,允许我们指明在转换后的数据中需要多少个组件。然而,有时问题在于我们不知道需要多少个组件。

线性判别分析

线性判别分析LDA)是一种技术,其结果与我们的数据集中拥有的组件数量相同。这意味着我们数据集中的列数与 LDA 提供的组件数相同。这反过来又意味着我们需要定义一个变量作为算法的依赖变量。

LDA 算法以这种方式在低维空间中对数据集进行投影,使得它能够将数据分离到依赖变量的类别中。因此,我们需要一个。以下代码片段说明了在数据集上使用 LDA 的方法:

from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
# create the classifier
# please note that we can only use one component, because
# we have only one predicted variable
lda = LinearDiscriminantAnalysis(n_components=1)
# fit to the data
# please note that this transformation requires the predicted
# variable too
dfDataAnt13LDA = lda.fit(dfDataAnt13Pred, dfDataAnt13.Defect).transform(dfDataAnt13Pred)
# print the transformed data
dfDataAnt13LDA[:3]

结果 DataFrame 只包含一个组件,因为我们数据集中只有一个依赖变量。

自动编码器

近年来,一种新的特征提取技术越来越受欢迎——自动编码器。自动编码器是一种特殊的神经网络,旨在将一种类型的数据转换成另一种类型的数据。通常,它们被用来以略微修改的形式重建输入数据。例如,它们可以用来去除图像中的噪声或将图像转换为使用不同画笔风格的图像。

自动编码器非常通用,可以用于其他类型的数据,我们将在本章的剩余部分学习这些内容(例如,用于图像数据)。图 7.8展示了自动编码器的概念模型。它由两部分组成——编码器和解码器。编码器的作用是将输入数据——在这个例子中是一个图像——转换成抽象表示。这种抽象表示存储在特定的层(或几层),称为瓶颈。瓶颈的作用是存储允许解码器重建数据的输入数据的属性。解码器的作用是从瓶颈层获取数据的抽象表示,并尽可能好地重建输入数据:

图 7.8 – 自动编码器的概念可视化。这里,输入数据是图像的形式

图 7.8 – 自动编码器的概念可视化。这里,输入数据是图像的形式

由于自动编码器被训练以尽可能好地重建数据,瓶颈值通常被认为是对输入数据的良好内部表示。这种表示如此之好,以至于它允许我们区分不同的输入数据点。

瓶颈值也非常灵活。与之前介绍的技术不同,我们没有限制可以提取多少特征。如果我们需要,我们甚至可以提取比我们数据集中列数更多的特征,尽管这样做没有太多意义。

因此,让我们构建一个用于从设计用来学习缺陷数据表示的自动编码器中提取特征的管道:

下面的代码片段展示了如何读取数据集并从中移除有缺陷的列:

# read the file with data using openpyxl
import pandas as pd
# we read the data from the excel file,
# which is the defect data from the ant 1.3 system
dfDataAnt13 = pd.read_excel('./chapter_6_dataset_numerical.
              xlsx',sheet_name='ant_1_3',index_col=0)
# let's remove the defect column, as this is the one that we could
# potentially predict
X = dfDataAnt13.drop(['Defect'], axis = 1)
y = dfDataAnt13.Defect

除了移除列之外,我们还需要对数据进行缩放,以便自动编码器有很好的机会识别所有列中的小模式:

# split into train test sets
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.33, random_state=1)
# scale data
t = MinMaxScaler()
t.fit(X_train)
X_train = t.transform(X_train)
X_test = t.transform(X_test)

现在,我们可以创建我们的自动编码器的编码器部分,它将在下面的代码片段中展示:

# number of input columns
n_inputs = X.shape[1]
# the first layer - the visible one
visible = Input(shape=(n_inputs,))
# encoder level 1
e = Dense(n_inputs*2)(visible)
e = BatchNormalization()(e)
e = LeakyReLU()(e)
# encoder level 2
e = Dense(n_inputs)(e)
e = BatchNormalization()(e)
e = LeakyReLU()(e)

之前的代码创建了自动编码器的两个级别,因为我们的数据相当简单。现在,有趣的部分是瓶颈,可以通过运行以下代码来创建:

n_bottleneck = 3
bottleneck = Dense(n_bottleneck)(e)

在我们的案例中,瓶颈非常窄——只有三个神经元——因为数据集相对较小,且并不复杂。在下一部分,当我们使用自动编码器处理图像时,我们将看到瓶颈可以更宽。一般的思想是,更宽的瓶颈允许我们捕捉数据中的更复杂依赖关系。例如,对于彩色图像,我们需要更多的神经元,因为我们需要捕捉颜色,而对于灰度图像,我们需要更窄的瓶颈。

最后,我们可以使用以下代码创建自动编码器的解码器部分:

# define decoder, level 1
d = Dense(n_inputs)(bottleneck)
d = BatchNormalization()(d)
d = LeakyReLU()(d)
# decoder level 2
d = Dense(n_inputs*2)(d)
d = BatchNormalization()(d)
d = LeakyReLU()(d)
# output layer
output = Dense(n_inputs, activation='linear')(d)

构造过程的最后一部分是将这三个部分放在一起——编码器、瓶颈和解码器。我们可以使用以下代码来完成这项工作:

# we place both of these into one model
# define autoencoder model
model = Model(inputs=visible, outputs=output)
# compile autoencoder model
model.compile(optimizer='adam', loss='mse')

到目前为止,我们已经构建了我们的自动编码器。我们已经定义了其层和瓶颈。现在,自动编码器必须被训练以理解如何表示我们的数据。我们可以使用以下代码来完成:

# we train the autoencoder model
history = model.fit(X_train, X_train,
                    epochs=100,
                    batch_size=16,
                    verbose=2,
                    validation_data=(X_test,X_test))

请注意,我们使用相同的数据作为输入和验证,因为我们需要训练编码器尽可能准确地重新创建相同的数据,考虑到瓶颈层的大小。在训练编码器模型后,我们可以使用它来从模型中提取瓶颈层的值。我们可以通过定义一个子模型并使用它作为输入数据来完成:

submodel = Model(model.inputs, model.get_layer("dense_8").output)
# this is the actual feature extraction -
# where we make prediction for the train dataset
# please note that the autoencoder requires a two dimensional array
# so we need to take one datapoint and make it into a two dimensional array
# with only one row
results = submodel.predict(np.array([X_train[0]]))
results[0]

执行此代码的结果是一个包含三个值的向量——自动编码器的瓶颈值。

我在本章中的下一个最佳实践与自动编码器的使用相关。

最佳实践 #42

当数据集非常大时,使用自动编码器对数值数据进行处理,因为自动编码器复杂且需要大量数据进行训练。

由于特征的质量是自动编码器训练效果的一个函数,我们需要确保训练数据集足够大。因此,自动编码器通常用于图像数据。

图像数据的特征工程

对于图像数据,最突出的特征提取方法之一是使用卷积神经网络CNNs)并从这些网络中提取嵌入。近年来,引入了这种类型神经网络的一种新类型——自动编码器。虽然我们可以使用自动编码器处理各种数据,但它们特别适合图像。因此,让我们为 MNIST 数据集构建一个自动编码器,并从中提取瓶颈层的值。

首先,我们需要使用以下代码片段下载 MNIST 数据集:

# first, let's read the image data from the Keras library
from tensorflow.keras.datasets import mnist
# and load it with the pre-defined train/test splits
(X_train, y_train), (X_test, y_test) = mnist.load_data()
X_train = X_train/255.0
X_test = X_test/255.0

现在,我们可以使用以下代码构建编码器部分。请注意,编码器部分有一个额外的层。该层的目的是将二维图像转换为一维输入数组——即展平图像:

# image size is 28 pixels
n_inputs = 28
# the first layer - the visible one
visible = Input(shape=(n_inputs,n_inputs,))
# encoder level 1
e = Flatten(input_shape = (28, 28))(visible)
e = LeakyReLU()(e)
e = Dense(n_inputs*2)(e)
e = BatchNormalization()(e)
e = LeakyReLU()(e)
# encoder level 2
e = Dense(n_inputs)(e)
e = BatchNormalization()(e)
e = LeakyReLU()(e)

现在,我们可以构建我们的瓶颈层。在这种情况下,瓶颈层可以更宽,因为图像比我们之前在自动编码器中使用的模块数值数组更复杂(而且数量更多):

n_bottleneck = 32
bottleneck = Dense(n_bottleneck)(e)

解码器部分与之前的例子非常相似,但有一个额外的层,该层可以从其扁平表示中重新创建图像:

# and now, we define the decoder part
# define decoder, level 1
d = Dense(n_inputs)(bottleneck)
d = BatchNormalization()(d)
d = LeakyReLU()(d)
# decoder level 2
d = Dense(n_inputs*2)(d)
d = BatchNormalization()(d)
d = LeakyReLU()(d)
# output layer
d = Dense(n_inputs*n_inputs, activation='linear')(d)
output = Reshape((28,28))(d)

现在,我们可以编译和训练自动编码器:

# we place both of these into one model
# define autoencoder model
model = Model(inputs=visible, outputs=output)
# compile autoencoder model
model.compile(optimizer='adam', loss='mse')
# we train the autoencoder model
history = model.fit(X_train, X_train,
                    epochs=100,
                    batch_size=16,
                    verbose=2,
                    validation_data=(X_test,X_test))

最后,我们可以从模型中提取瓶颈层的值:

submodel = Model(model.inputs, bottleneck)
# this is the actual feature extraction -
# where we make prediction for the train dataset
# please note that the autoencoder requires a two dimensional array
# so we need to take one datapoint and make it into a two dimensional array
# with only one row
results = submodel.predict(np.array([X_train[0]]))
results[0]

现在,结果值的数组要大得多——它有 32 个值,与我们瓶颈层中的神经元数量相同。

瓶颈层中的神经元数量基本上是任意的。以下是选择神经元数量的最佳实践。

最佳实践 #43

在瓶颈层开始时使用较少的神经元数量——通常是列数的三分之一。如果自动编码器没有学习,可以逐渐增加数量。

我选择 1/3 的列数并没有具体的原因,只是基于经验。你可以从相反的方向开始——将瓶颈层做得和输入层一样宽——然后逐渐减小。然而,拥有与列数相同数量的特征并不是我们最初使用特征提取的原因。

摘要

在本章中,我们的重点是特征提取技术。我们探讨了如何使用降维技术和自动编码器来减少特征数量,以便使机器学习模型更加有效。

然而,数值和图像数据只是数据类型中的两个例子。在下一章中,我们将继续介绍特征工程方法,但对于文本数据,这在当代软件工程中更为常见。

参考文献

  • Zheng, A. 和 A. Casari,机器学习特征工程:数据科学家原理与技术。2018 年:O’Reilly 媒体公司

  • Heaton, J. 对预测建模中特征工程的经验分析。在 2016 年东南会议. 2016 年,IEEE。

  • Staron, M. 和 W. Meding,软件开发度量计划。Springer。doi.org/10.1007/978…. 第 10 卷. 2018 年,3281333.

  • Abran, A.,软件度量与软件计量学。2010 年:John Wiley & Sons。

  • Meng, Q.,等人。关系自动编码器用于特征提取。在 2017 年国际神经网络联合会议(IJCNN)。 2017 年,IEEE。

  • Masci, J.,等人。用于层次特征提取的堆叠卷积自动编码器。在人工神经网络与机器学习,ICANN 2011:第 21 届国际人工神经网络会议,芬兰埃斯波,2011 年 6 月 14-17 日,第 21 卷. 2011 年,Springer。

  • Rumelhart, D.E.,G.E. Hinton,和 R.J. Williams,通过反向传播错误学习表示。自然,1986 年,323(6088): p. 533-536.

  • Mosin, V.,等人,比较基于自动编码器的高速公路驾驶场景图像异常检测方法。SN 应用科学,2022 年,4(12): p. 334.

第八章:自然语言数据的特征工程

在上一章中,我们探讨了如何从数值数据和图像中提取特征,并探讨了用于此目的的几个算法。在本章中,我们将继续探讨用于从自然语言数据中提取特征的算法。

自然语言是软件工程中的一种特殊数据源。随着 GitHub Copilot 和 ChatGPT 的引入,变得明显的是,用于软件工程任务的机器学习和人工智能工具不再是科幻。因此,在本章中,我们将探讨使这些技术变得如此强大的第一步——从自然语言数据中提取特征。

在本章中,我们将涵盖以下主题:

  • 标记化器及其在特征提取中的作用

  • 词袋作为处理自然语言数据的一种简单技术

  • 作为更高级方法,词嵌入可以捕捉上下文

软件工程中的自然语言数据和 GitHub Copilot 的兴起

编程一直是科学、工程和创造力的结合。创建新的程序和能够指导计算机执行某些操作一直是被认为值得付费的事情——这就是所有程序员谋生的手段。人们尝试过自动化编程和支持较小的任务——例如,为程序员提供如何使用特定函数或库方法的建议。

然而,优秀的程序员可以编写出持久且易于他人阅读的程序。他们还可以编写出长期无需维护的可靠程序。最好的程序员是那些能够解决非常困难任务并遵循软件工程原则和最佳实践的程序员。

在 2020 年,发生了某件事情——GitHub Copilot 登上了舞台,并展示了基于大型语言模型LLMs)的自动化工具不仅能提供简单的函数调用建议,还能提供更多。它已经证明,这些语言模型能够提供整个解决方案和算法的建议,甚至能够解决编程竞赛。这为程序员开辟了全新的可能性——最优秀的程序员变得极其高效,并得到了允许他们专注于编程任务复杂部分的工具。简单的任务现在由 GitHub Copilot 和其他工具解决。

这些工具之所以如此出色,是因为它们基于 LLMs,能够找到并量化程序的上下文。就像一位伟大的棋手可以提前预见几步棋一样,这些工具可以提前预见程序员可能需要什么,并提供有用的建议。

有一些简单的技巧使得这些工具如此有效,其中之一就是特征工程。自然语言任务(包括编程)的特征工程是一个将文本片段转换为数字向量(或矩阵)的过程。这些向量可以是简单的——例如,量化标记——也可以是非常复杂的——例如,找到与其他任务相关联的原子文本片段。我们将在本章中探讨这些技术。我们将从稍微重复一下第三章和第五章中看到的词袋技术(见第三章第五章)开始。我们不需要重复整个代码,但我们确实需要提供一个小的总结来理解这些方法的局限性。然而,这是我选择是否需要分词器或嵌入的最佳实践。

最佳实践 #44

对于 LLM(如 BERT)使用分词器,对于简单任务使用词嵌入。

对于简单的任务,例如文本的基本分词用于情感分析或快速理解文本中的依赖关系,我通常使用词嵌入。然而,当与 LLM(如 BERT、RoBERTa 或 AlBERT)一起工作时,我通常使用不同的分词器,因为这些模型在自身寻找依赖关系方面非常出色。然而,在设计分类器时,我使用词嵌入,因为它们提供了一种快速创建与“经典”机器学习算法兼容的特征向量的方法。

选择分词器需要根据任务来决定。我们将在本章中更详细地探讨这个问题,但这个主题本身可能需要一整本书来阐述。例如,对于需要关于词性(或者在很多情况下,程序抽象语法树的某一部分)的信息的任务,我们需要使用专门设计来捕获这些信息的分词器——例如,从编程语言解析器中获取。这些分词器为模型提供了更多信息,但它们对数据的要求也更高——基于抽象语法树的分词器要求程序具有良好的格式。

分词器是什么以及它做什么

特征工程文本数据的第一个步骤是决定文本的分词。文本分词是一个提取能够捕捉文本意义而不包含太多额外细节的词的部分的过程。

有不同的方法来提取标记,我们将在本章中探讨这些方法,但为了说明提取标记的问题,让我们看看一个可以采取不同形式的单词——print。这个单词本身可以是一个标记,但它可以有不同的形式,如printingprintedprinterprintsimprinted等等。如果我们使用简单的分词器,这些单词中的每一个都将是一个标记——这意味着有很多标记。然而,所有这些标记都捕捉到与打印相关的某种意义,所以可能我们不需要这么多。

这就是分词器发挥作用的地方。在这里,我们可以决定如何处理这些不同的词形。我们可以只取主要部分——print——然后所有其他形式都会被计为那样,所以imprintedprinting都会被计为print。这减少了标记的数量,但我们也减少了特征向量的表达性——一些信息丢失了,因为我们没有相同数量的标记可以使用。我们可以预先设计一组标记——也就是说,使用printimprint来区分不同的上下文。我们也可以使用双词(两个词一起)作为标记(例如,is_goingisgoing——第一个需要两个词以特定顺序出现,而第二个允许它们出现在两个不同的序列中),或者我们可以添加关于单词是否为句子中主语对象的信息。

词袋和简单分词器

第三章第五章中,我们看到了词袋特征提取技术的应用。这种技术对文本进行计数,统计标记的数量,在第三章第五章中是单词。它简单且计算效率高,但有几个问题。

当实例化词袋分词器时,我们可以使用几个参数,这些参数会强烈影响结果,就像我们在前几章的代码片段中所做的那样:

# create the feature extractor, i.e., BOW vectorizer
# please note the argument - max_features
# this argument says that we only want three features
# this will illustrate that we can get problems - e.g. noise
# when using too few features
vectorizer = CountVectorizer(max_features = 3)

max_features参数是一个截止值,它减少了特征的数量,但它也可能在两个(或更多)不同句子具有相同特征向量时引入噪声(我们在第二章中看到了这样一个句子的例子)。由于我们已经讨论了噪声及其相关问题,我们可能会倾向于使用其他参数——max_dfmin_df。这两个参数决定了单词在文档中应该出现多少次才能被认为是标记。过于罕见的标记(min_df)可能导致稀疏矩阵——特征矩阵中有许多 0——但它们可以在数据点之间提供很好的区分。也许这些罕见的单词正是我们所寻找的。另一个参数(max_df)导致更密集的特征矩阵,但它们可能无法完全区分数据点。这意味着选择这些参数并不简单——我们需要实验,并使用机器学习模型训练(和验证)来找到正确的向量。

除此之外,还有一种方法——我们可以执行递归搜索以找到这样一个特征向量,它能够区分所有数据点而不会添加太多噪声。我的团队已经尝试过这样的算法,这些算法在模型训练和验证方面表现出色,但计算成本非常高。这种算法在图 8.1中展示:

图 8.1 – 一种在文本文件中找到一组能够区分所有数据点的特征算法。流程已被简化以说明主要点

图 8.1 – 一个用于在文本文件中找到一组能够区分所有数据点的特征的算法。流程已被简化以说明主要观点

该算法通过添加新标记来工作,如果数据点具有与之前任何数据点相同的特征向量。它首先从第一行取第一个标记,然后是第二行。如果标记可以区分这两行,则继续到第三行。一旦算法发现两行具有不同的特征向量,它就会找出是否存在可以区分这些行的标记,并将其添加到特征集。它继续添加,直到没有新标记可以添加或所有行都已分析。

此算法保证找到最佳区分分析数据集的标记集。然而,它有一个很大的缺点——它很慢(因为它必须从找到/需要新标记的第一行开始)。生成的特征矩阵也不是最优的——它包含很多 0,因为大多数标记只能在一行中找到。反过来,特征矩阵可能比实际原始数据集大得多。

这就是我的下一个最佳实践发挥作用的地方。

最佳实践 #45

当你的任务需要预定义的单词集时,请使用词袋模型分词器并结合字典。

在分析编程语言代码时,我经常使用词袋模型分词器。我使用编程语言中预定义的关键词集来增强分词器,然后使用标准的CountVectorizer。这使我能够控制我感兴趣的部分词汇量——关键词——并允许分词器适应文本。

WordPiece 分词器

从文本文档中分词和提取特征的一个更好的方法是使用 WordPiece 分词器。这种分词器以这样的方式工作,即它找到它可以区分的最常见的文本片段,以及最常见的那些。这种类型的分词器需要训练——也就是说,我们需要提供一组代表性文本以获得正确的词汇(标记)。

让我们来看一个例子,我们使用一个简单的程序,一个开源项目中的模块,来训练这样的分词器,然后将这个分词器应用于著名的“Hello World”C 语言程序。让我们首先创建分词器:

from tokenizers import BertWordPieceTokenizer
# initialize the actual tokenizer
tokenizer = BertWordPieceTokenizer(
    clean_text=True,
    handle_chinese_chars=False,
    strip_accents=False,
    lowercase=True
)

在这个例子中,我们使用 Hugging Face 库中的 WordPiece 分词器,特别是为与 BERT 等 LLM 一起工作而准备的分词器。我们可以使用几个参数,但让我们只使用显示我们只对小写字母感兴趣;我们不希望处理中文字符,并希望从头开始。

现在,我们需要找到一个可以用来训练分词器的文本片段。在这个例子中,我将使用开源项目中的一个文件 – AzureOS NetX。它是一个用 C 语言编写的组件,用于处理互联网 HTTP 协议的部分。我们创建一个新的变量 – path – 并将文件的路径添加到那里。一旦我们准备好了文本,我们就可以训练分词器:

# and train the tokenizer based on the text
tokenizer.train(files=paths,
                vocab_size=30_000,
                min_frequency=1,
                limit_alphabet=1000,
                wordpieces_prefix='##',
                special_tokens=['[PAD', '[UNK]', '[CLS]', '[SEP]', '[MASK]'])

我们已经将分词器设置成与之前示例中的CountVectorizer相似的一组参数。这个前代码片段找到了最常见的单词片段并将它们用作标记。

我们可以通过tokenizer.get_vocab()语句获取标记列表,这将产生一个长的标记字典。以下是前几个标记:

'##ll': 183,
'disable': 326,
'al': 263,
'##cket': 90,
'##s': 65,
'computed': 484

第一个标记是单词的一部分,这通过它开头有两个井号的事实来表示。这个标记在词汇表中映射到数字183。这种映射很重要,因为数字在后续的机器学习模型中会被使用。

另一个有趣的观察是,一些标记,如'disable',不是单词的一部分,而是整个单词。这意味着这个标记在任何地方都没有作为单词的一部分出现,并且它不包含词汇表中其他单词的任何部分。

一旦我们训练了 WordPiece 分词器,我们可以检查分词器如何从一个简单的 C 程序中提取特征:

strCProgram = '''
int main(int argc, void **argc)
{
  printf("%s", "Hello World\n");
  return 0;
}
'''
# now, let's see how the tokenizer works
# we invoke it based on the program above
tokenizedText = tokenizer.encode(strCProgram)
tokenizedText.tokens

前面的代码片段对程序进行了分词。结果是以下标记列表(只显示了 50 个标记中的前 10 个):

'in', '##t', 'ma', '##in', '(', 'in', '##t', 'a', '##r', '##g'

第一行,以int标记开始,已经被以下方式分词。第一个单词 – int – 被分割成两个标记:"in""##t"。这是因为这两个部分被用于训练程序中。我们还可以看到第二个标记 – main – 被分割成两个标记:"ma""##in"。这些标记的 ID 如下:

110, 57, 272, 104, 10, 110, 57, 30, 61, 63

这意味着这个数字列表是我们简单 C 程序的特征向量。

WordPiece 分词非常有效,但它很大程度上依赖于训练数据。如果我们使用与分词文本非常不同的训练数据,标记集将不会很有帮助。因此,我的下一个最佳实践是关于训练这个分词器。

最佳实践 #46

将 WordPiece 分词器作为首选。

我通常将这个分词器作为首选。它相对灵活但相当快速。它允许我们捕获一个词汇表,大多数时候都能完成任务,并且不需要很多设置。对于具有直接语言和明确定义词汇的简单任务,传统的词级分词或其他子词分词方法,如字节对编码BPE)可能就足够了。WordPiece 分词可能会由于引入子词标记而增加输入数据的大小。这可能会影响内存和计算需求。

BPE

文本标记化的一个更高级的方法是 BPE 算法。这个算法基于与 20 世纪 90 年代由 Gage 创建的压缩算法相同的原理。该算法通过压缩数据中未使用的字节来压缩一系列字节。BPE 标记化程序做的是类似的事情,只不过它用未在文本中使用的新的字节替换了一系列标记。这样,该算法可以创建比CountVectorizer和 WordPiece 标记化程序更大的词汇表。BPE 因其处理大型词汇表的能力和通过 fastBPE 库的高效实现而非常受欢迎。

让我们探讨如何将这个标记化程序应用于相同的数据,并检查与前两种方法的差异。以下代码片段展示了如何从 Hugging Face 库中实例化这个标记化程序:

# in this example we use the tokenizers
# from the HuggingFace library
from tokenizers import Tokenizer
from tokenizers.models import BPE
# we instantiate the tokenizer
tokenizer = Tokenizer(BPE(unk_token="[UNK]"))

这个标记化程序需要训练,因为它需要找到最优的标记对集合。因此,我们需要实例化一个训练类并对其进行训练。以下代码片段正是这样做的:

from tokenizers.trainers import BpeTrainer
# here we instantiate the trainer
# which is a specific class that will manage
# the training process of the tokenizer
trainer = BpeTrainer(special_tokens=["[UNK]", "[CLS]",
                     "[SEP]", "[PAD]", "[MASK]"])
from tokenizers.pre_tokenizers import Whitespace
tokenizer.pre_tokenizer = Whitespace()
# now, we need to prepare a dataset
# in our case, let's just read a dataset that is a code of a program
# in this example, I use the file from an open-source component - Azure NetX
# the actual part is not that important, as long as we have a set of
# tokens that we want to analyze
paths = ['/content/drive/MyDrive/ds/cs_dos/nx_icmp_checksum_compute.c']
# finally, we are ready to train the tokenizer
tokenizer.train(paths, trainer)

这个训练过程中的重要部分是使用一个特殊的预标记化程序。预标记化程序是我们最初将单词分割成标记的方式。在我们的案例中,我们使用标准的空白字符,但我们可以使用更高级的方法。例如,我们可以使用分号,因此可以将整行代码作为标记。

执行上述代码片段后,我们的标记化程序已经训练完毕,可以使用了。我们可以通过编写tokenizer.get_vocab()来检查标记。以下是一些标记(前 10 个标记):

'only': 565, 'he': 87, 'RTOS': 416, 'DE': 266, 'CH': 154, 'a': 54, 'ps': 534, 'will': 372, 'NX_SHIFT_BY': 311, 'O': 42,

这组标记与之前案例中的标记集非常不同。它包含了一些单词,如“will”,和一些子词,如“ol.”。这是因为 BPE 标记化程序发现了一些重复的标记,并用专门的字节替换了它们。

最佳实践 #47

在处理大型语言模型和大量文本语料库时使用 BPE。

当我分析大量文本时,例如大型代码库,我会首选使用 BPE。这项任务对 BPE 来说非常快速,并且能够捕捉复杂的依赖关系。它也在 BERT 或 GPT 等模型中被大量使用。

现在,在我们的案例中,我们用来训练 BPE 标记化程序的源代码很小,所以很多单词没有重复出现,优化并没有太多意义。因此,WordPiece 标记化程序可以完成同样(如果不是更好)的工作。然而,对于更大的文本语料库,这个标记化程序比 WordPiece 或词袋模型更有效率和高效。它也是下一个标记化程序——SentencePiece 的基础。

SentencePiece 标记化程序

句子分割(SentencePiece)比 BPE 更通用,还有一个原因:它允许我们将空白视为常规标记。这使我们能够找到更复杂的依赖关系,因此可以训练出理解不仅仅是单词片段的模型。因此得名——句子分割。这个分词器最初是为了使像日语这样的语言(例如,与英语不同,日语不使用空白)的标记化成为可能。可以通过运行pip install -q sentencepiece命令来安装这个分词器。

在以下代码示例中,我们实例化和训练了 SentencePiece 分词器:

import sentencepiece as spm
# this statement trains the tokenizer
spm.SentencePieceTrainer.train('--input="/content/drive/MyDrive/ds/cs_dos/nx_icmp_checksum_compute.c" --model_prefix=m --vocab_size=200')
# makes segmenter instance and
# loads the model file (m.model)
sp = spm.SentencePieceProcessor()
sp.load('m.model')

我们在与其他分词器相同的文件上对其进行了训练。文本是一个编程文件,因此我们可以预期分词器能比正常文本更好地理解编程语言的内容。值得注意的是词汇表的大小,它是 200,而之前的例子中是 30,000。这是因为这个分词器试图找到尽可能多的标记。由于我们的输入程序非常短——一个包含几个函数的文件——分词器不能创建超过大约 300 个标记。

以下片段使用这个分词器对“Hello World”程序进行编码,并打印以下输出:

strCProgram = '''
int main(int argc, void **argc)
{
  printf("%s", "Hello World\n");
  return 0;
}
'''
print(sp.encode_as_pieces(strCProgram))

前十个标记的表示方式如下:

'▁in', 't', '▁', 'm', 'a', 'in', '(', 'in', 't', '▁a'

在这个分词器中引入的新元素是下划线字符(_)。它在文本中表示空白。这是独特的,它使我们能够更有效地在编程语言理解中使用这个分词器,因为它允许我们捕获诸如嵌套之类的编程结构——也就是说,使用制表符而不是空格,或者在同一行中编写多个语句。这一切都是因为这个分词器将空白视为重要的事物。

最佳实践 #48

当没有明显的单词边界时,请使用 SentencePiece 分词器。

当分析编程语言代码并关注编程风格时,我会使用 SentencePiece——例如,当我们关注诸如驼峰式变量命名等问题时。对于这个任务,理解程序员如何使用空格、格式化和其他编译器透明的元素非常重要。因此,这个分词器非常适合这样的任务。

词嵌入

分词器是从文本中提取特征的一种方法。它们功能强大,可以训练以创建复杂的标记并捕获单词的统计依赖关系。然而,它们受限于它们是完全无监督的,并且不捕获任何单词之间的意义或关系。这意味着分词器非常适合为神经网络模型,如 BERT,提供输入,但有时我们希望有与特定任务更对齐的特征。

这就是词嵌入发挥作用的地方。以下代码展示了如何实例化从gensim库导入的词嵌入模型。首先,我们需要准备数据集:

from gensim.models import word2vec
# now, we need to prepare a dataset
# in our case, let's just read a dataset that is a code of a program
# in this example, I use the file from an open source component - Azure NetX
# the actual part is not that important, as long as we have a set of
# tokens that we want to analyze
path = '/content/drive/MyDrive/ds/cs_dos/nx_icmp_checksum_compute.c'
# read all lines into an array
with open(path, 'r') as r:
  lines = r.readlines()
# and see how many lines we got
print(f'The file (and thus our corpus) contains {len(lines)} lines')

与之前的标记化器相比,前面的代码片段以不同的方式准备文件。它创建了一个行列表,每行是一个由空格分隔的标记列表。现在,我们已经准备好创建word2vec模型并在这些数据上训练它:

# we need to pass splitted sentences to the model
tokenized_sentences = [sentence.split() for sentence in lines]
model = word2vec.Word2Vec(tokenized_sentences,
                          vector_size=10,
                          window=1,
                          min_count=0,
                          workers=4)

结果是,该模型是在我们提供的语料库上训练的——实现 HTTP 协议一部分的 C 程序。我们可以通过编写model.wv.key_to_index来查看已提取的前 10 个标记:

'*/': 0, '/*': 1, 'the': 2, '=': 3, 'checksum': 4, '->': 5, 'packet': 6, 'if': 7, 'of': 8, '/**********************************************************************/': 9,

总共,word2vec提取了 259 个标记。

与我们之前使用的标记化器不同,这个词嵌入模型将词(标记)的值嵌入到一个潜在空间中,这使得我们可以更智能地利用这些词的词汇属性。例如,我们可以使用model.wv.most_similar(positive=['add'])来检查词的相似性:

('NX_LOWER_16_MASK;', 0.8372778296470642),
('Mask', 0.8019374012947083),
('DESCRIPTION', 0.7171915173530579),

我们也可以假设这些词是向量,它们的相似性被这个向量捕捉。因此,我们可以写一些类似的东西,比如 model.wv.most_similar(positive= ['file', 'function'], negative=['found']) 并获得如下结果:

('again', 0.24998697638511658),
('word', 0.21356187760829926),
('05-19-2020', 0.21174617111682892),
('*current_packet;', 0.2079058289527893),

如果我们用数学来表示这个表达式,那么结果将是:result = file + function – found。这个相似词列表是距离这个计算结果捕获的向量最近的词列表。

当我们想要捕捉词和表达式的相似性时,词嵌入非常强大。然而,该模型的原始实现存在某些限制——例如,它不允许我们使用原始词汇表之外的词。请求与未知标记(例如,model.wv.most_similar(positive=['return']))相似的词会导致错误。

FastText

幸运的是,有一个word2vec模型的扩展可以近似未知标记——FastText。我们可以用与使用word2vec非常相似的方式使用它:

from gensim.models import FastText
# create the instance of the model
model = FastText(vector_size=4,
                 window=3,
                 min_count=1)
# build a vocabulary
model.build_vocab(corpus_iterable=tokenized_sentences)
# and train the model
model.train(corpus_iterable=tokenized_sentences,
            total_examples=len(tokenized_sentences),
            epochs=10)

在前面的代码片段中,模型是在与word2vec相同的 数据集上训练的。model = FastText(vector_size=4, window=3, min_count=1) 创建了一个具有三个超参数的 FastText 模型实例:

  • vector_size:结果特征向量中的元素数量

  • window:用于捕捉上下文词的窗口大小

  • min_count:要包含在词汇表中的单词的最小频率

model.build_vocab(corpus_iterable=tokenized_sentences)通过遍历tokenized_sentences可迭代对象(该对象应包含一个列表的列表,其中每个内部列表代表一个句子被分解成单个单词)并将每个单词添加到词汇表中,如果它满足min_count阈值。model.train(corpus_iterable=tokenized_sentences, total_examples=len(tokenized_sentences), epochs=10)使用tokenized_sentences可迭代对象训练 FastText 模型,总共 10 个 epoch。在每个 epoch 中,模型再次遍历语料库,并根据每个目标词周围的上下文单词更新其内部权重。total_examples参数告诉模型语料库中有多少个总示例(即句子),这用于计算学习率。

输入是相同的。然而,如果我们调用未知标记的相似度,例如model.wv.most_similar(positive=['return']),我们会得到以下结果:

('void', 0.5913326740264893),
('int', 0.43626993894577026),
('{', 0.2602742612361908),

这三个相似词的集合表明模型可以近似未知标记。

我接下来的最佳实践是关于 FastText 的使用。

最佳实践 #49

使用词嵌入,如 FastText,作为文本分类任务的有价值特征表示,但考虑将其纳入更全面的模型以实现最佳性能。

除非我们需要使用 LLM,这种特征提取是简单词袋技术以及强大的 LLM 的绝佳替代方案。它捕捉到一些含义的部分,并允许我们基于文本数据设计分类器。它还可以处理未知标记,这使得它非常灵活。

从特征提取到模型

本章中提出的特征提取方法并非我们唯一能使用的。至少还有更多(更不用说其他方法了)。然而,它们的工作原理相似。不幸的是,没有一劳永逸的解决方案,所有模型都有其优势和劣势。对于同一任务,但不同的数据集,简单的模型可能比复杂的模型更好。

现在我们已经看到了如何从文本、图像和数值数据中提取特征,现在是时候开始训练模型了。这就是我们在下一章将要做的。

参考文献

  • Al-Sabbagh, K.W., et al. Selective regression testing based on big data: comparing feature extraction techniques. in 2020 IEEE International Conference on Software Testing, Verification and Validation Workshops (ICSTW). 2020. IEEE.

  • Staron, M., et al. Improving Quality of Code Review Datasets–Token-Based Feature Extraction Method. in Software Quality: Future Perspectives on Software Engineering Quality: 13th International Conference, SWQD 2021, Vienna, Austria, January 19–21, 2021, Proceedings 13. 2021. Springer.

  • Sennrich, R., B. Haddow, and A. Birch, Neural machine translation of rare words with subword units. arXiv preprint arXiv:1508.07909, 2015.

  • Gage, P., A new algorithm for data compression. C Users Journal, 1994. 12(2): p. 23-38.

  • Kudo, T. 和 J. Richardson, SentencePiece:一种简单且语言无关的子词分词和去分词器,用于神经文本处理。arXiv 预印本 arXiv:1808.06226, 2018.

第三部分:机器学习系统设计与开发

尽管机器学习和其近亲人工智能广为人知,但它们涉及广泛的算法和模型。首先,有基于统计学习的经典机器学习模型,通常需要数据以表格形式准备。它们在数据中识别模式,并能复制这些模式。然而,也有基于深度学习的现代模型,能够捕捉到更精细的数据模式,这些数据结构相对较少。这些模型的典范是转换器模型(GPT)和自编码器(扩散器)。在本部分书中,我们更深入地探讨这些模型。我们关注这些模型如何被训练和集成到机器学习管道中。我们还探讨如何将这些模型应用于软件工程实践。

本部分包含以下章节:

  • 第九章, 机器学习系统类型 – 基于特征和原始数据(深度学习)

  • 第十章, 经典机器学习系统和神经网络的训练与评估

  • 第十一章, 高级机器学习算法的训练与评估 – GPT-3 和自编码器

  • 第十二章, 设计机器学习管道及其测试

  • 第十三章, 大规模、鲁棒机器学习软件的设计与实现

第九章:机器学习系统类型——基于特征和基于原始数据(深度学习)

在前几章中,我们学习了数据、噪声、特征和可视化。现在,是时候转向机器学习模型了。没有一种单一的模型,但有很多种——从经典的模型,如随机森林,到用于视觉系统的深度学习模型,再到生成式 AI 模型,如 GPT。

卷积和 GPT 模型被称为深度学习模型。它们的名称来源于它们使用原始数据作为输入,并且模型的第一层包括特征提取层。它们还设计为随着输入数据通过这些模型而逐步学习更抽象的特征。

本章演示了这些模型类型中的每一种,并从经典机器学习到生成式 AI 模型逐步推进。

本章将涵盖以下主题:

  • 为什么我们需要不同类型的模型?

  • 经典的机器学习模型和系统,例如随机森林、决策树和逻辑回归

  • 用于视觉系统的深度学习模型、卷积神经网络模型和你只需看一次YOLO)模型

  • 通用预训练转换器GPT)模型

为什么我们需要不同类型的模型?

到目前为止,我们在数据处理上投入了大量的努力,同时专注于诸如噪声减少和标注等任务。然而,我们还没有深入研究用于处理这些处理数据的模型。虽然我们简要提到了基于数据标注的不同类型模型,包括监督学习、无监督学习和强化学习,但我们还没有彻底探讨用户在利用这些模型时的视角。

在使用机器学习模型处理数据时,考虑用户的视角非常重要。用户的需求、偏好和具体要求在选择和使用适当的模型中起着至关重要的作用。

从用户的角度来看,评估诸如模型可解释性、集成简便性、计算效率和可扩展性等因素变得至关重要。根据应用和用例,用户可能会优先考虑模型的不同方面,例如准确性、速度或处理大规模数据集的能力。

此外,用户的领域专业知识和对底层算法的熟悉程度会影响模型的选择和评估。一些用户可能更喜欢简单、更透明的模型,这些模型提供可解释性和可理解性,而其他人可能愿意为了使用更复杂的模型(如深度学习网络)来提高预测性能而牺牲可解释性。

考虑用户的视角使模型选择和部署的方法更加全面。这涉及到积极地将用户纳入决策过程,收集反馈,并持续改进模型以满足他们的特定需求。

通过将用户的视角纳入讨论中,我们可以确保我们选择的模型不仅满足技术要求,而且与用户的期望和目标相一致,从而最终提高整个系统的有效性和可用性。

因此,在未来的工作中,我们将探讨不同类型的用户如何与各种机器学习模型互动并从中受益,同时考虑他们的具体需求、偏好和领域专业知识。我们将从经典的机器学习模型开始,这些模型在历史上是最先出现的。

经典机器学习模型

经典机器学习模型需要以表格和矩阵的形式预处理数据。例如,随机森林、线性回归和支持向量机等经典机器学习模型需要一个清晰的预测器和类别集合来发现模式。因此,我们需要为手头的任务手动设计预处理管道。

从用户的视角来看,这些系统是以非常经典的方式设计的——有一个用户界面、数据处理引擎(我们的经典机器学习模型)和输出。这如图 图 9*.1* 所示:

图 9.1 – 机器学习系统的要素

图 9.1 – 机器学习系统的要素

图 9*.1* 展示了有三个要素——输入提示、模型和输出。对于大多数这样的系统,输入提示是为模型提供的一组属性。用户填写某种形式的表格,系统提供答案。它可以是一个预测土地价格的表格,或者是一个贷款、求职、寻找最佳汽车的系统,等等。

这样的系统的源代码可能看起来像这样:

import pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error
# Load the stock price data into a pandas DataFrame
data = pd.read_csv('land_property_data.csv')
# Select the features (e.g., historical prices, volume, etc.) and the target variable (price)
X = data[['Feature1', 'Feature2', ...]]  # Relevant features here
y = data['price']
# read the model from the serialized storage here
# Make predictions on the test data
y_pred = model.predict(X)
# Evaluate the model using mean squared error (MSE)
print(f'The predicted value of the property is: {y_pred}')

这段代码需要模型已经训练好,并且只使用它来进行预测。使用模型的主要行是加粗的行。代码片段的其余部分用于处理输入,最后一行用于输出通信。

在现代生态系统中,机器学习模型的力量来自于无需大量更改代码就能改变模型的能力。大多数经典机器学习模型使用这种拟合/预测接口,正是这种接口使得这一点成为可能。那么,我们可以使用哪些机器学习模型呢?它们的种类实在太多,无法一一列举。然而,这些模型中的一些群体具有某些特性:

  • 回归模型将用于预测类别值的机器学习模型分组。它们既可以用于分类(将模块分类为易出故障或不),也可以用于预测任务(预测模块中的缺陷数量)。这些模型基于找到最佳曲线来拟合给定数据。

  • 基于树的模型将基于在数据集中寻找差异的模型分组,就像我们编写了一系列的 if-then 语句。这些 if-then 语句的逻辑条件基于数据的统计特性。这些模型适用于分类和预测模型。

  • 聚类算法将基于在数据中寻找相似性和将相似实体分组的模型分组。它们通常是未监督的,并且需要一些实验来找到正确的参数集(例如,簇的数量)。

  • 神经网络将所有可用于经典机器学习任务的神经网络分组。这些算法需要我们设计和训练神经网络模型。

我们可以根据这些模型的特点来选择它们,并通过测试找到最佳模型。然而,如果我们包括超参数训练,这个过程将非常耗时且费力。因此,我强烈推荐使用 AutoML 方法。AutoML 是一组算法,它们利用机器学习模型的 fit/predict 接口自动寻找最佳模型。通过探索众多模型,它们可以找到最适合数据集的模型。我们用星号表示这一点。有时,人类理解数据和其特性的能力会超过大多数自动机器学习过程(见博客文章)。

因此,这是本章的第一个最佳实践。

最佳实践#50

当您在训练经典机器学习模型时,请将 AutoML 作为首选。

使用 AutoML 非常简单,以下是从 auto-sklearn 文档中的代码片段可以说明这一点:

import autosklearn.classification
cls = autosklearn.classification.AutoSklearnClassifier()
cls.fit(X_train, y_train)
predictions = cls.predict(X_test)

前面的片段说明了使用 auto-sklearn 工具包寻找最佳模型是多么容易。请注意,这个工具包仅设计用于基于 Linux 的系统。要在 Microsoft Windows 操作系统上使用它,我建议使用Windows Subsystem for Linux 2.0WSL 2)。该界面以这种方式隐藏最佳模型,以至于用户甚至不需要看到哪个模型最适合当前的数据。

import autosklearn.classification导入专门用于分类任务的 auto-sklearn 模块。cls = autosklearn.classification.AutoSklearnClassifier()初始化AutoSklearnClassifier类的一个实例,它代表autosklearn中的 AutoML 分类器。它创建一个对象,该对象将用于自动搜索最佳分类器和其超参数。cls.fit(X_train, y_train)AutoSklearnClassifier拟合到训练数据。它自动探索不同的分类器和它们的超参数配置,以根据提供的X_train(特征)和y_train(目标标签)找到最佳模型。它在提供的训练数据集上训练 AutoML 模型。

predictions = cls.predict(X_test) 使用拟合的 AutoSklearnClassifierX_test 数据集进行预测。它将上一步找到的最佳模型应用于测试数据,并将预测的标签分配给 predictions 变量。

让我们在第六章中使用的相同数据集上应用 auto-sklearn:

# read the file with data using openpyxl
import pandas as pd
# we read the data from the excel file,
# which is the defect data from the ant 1.3 system
dfDataCamel12 = pd.read_excel('./chapter_6_dataset_numerical.xlsx',
                            sheet_name='camel_1_2',
                            index_col=0)
# prepare the dataset
import sklearn.model_selection
X = dfDataCamel12.drop(['Defect'], axis=1)
y = dfDataCamel12.Defect
X_train, X_test, y_train, y_test = \
        sklearn.model_selection.train_test_split(X, y, random_state=42, train_size=0.9)

我们将使用之前使用的相同代码:

import autosklearn.classification
cls = autosklearn.classification.AutoSklearnClassifier()
cls.fit(X_train, y_train)
predictions = cls.predict(X_test)

一旦我们训练了模型,我们可以检查它——例如,通过使用 print(cls.sprint_statistics()) 命令让 auto-sklearn 提供有关最佳模型的信息。结果如下:

auto-sklearn results:
Dataset name: 4b131006-f653-11ed-814a-00155de31e8a
Metric: accuracy
Best validation score: 0.790909
Number of target algorithm runs: 1273
Number of successful target algorithm runs: 1214
Number of crashed target algorithm runs: 59
Number of target algorithms that exceeded the time limit: 0
Number of target algorithms that exceeded the memory limit: 0

这条信息显示工具包已测试了 1273 个算法,其中有 59 个崩溃。这意味着它们与我们提供的数据集不兼容。

我们也可以使用 print(cls.show_models()) 命令让工具包为我们提供最佳模型。此命令提供了一系列用于集成学习的模型及其在最终得分中的权重。最后,我们可以使用 print(f\"Accuracy score {sklearn.metrics.accuracy_score(y_test, predictions):.2f}\") 来获取测试数据的准确度分数。对于这个数据集,测试数据的准确度分数为 0.59,这并不多。然而,这是通过使用最佳集成获得的模型。如果我们要求模型提供训练数据的准确度分数,我们将得到 0.79,这要高得多,但这是因为模型非常优化。

在本书的后面部分,我们将探讨这些算法,并学习它们在软件工程及其它任务中的行为。

卷积神经网络和图像处理

经典的机器学习模型相当强大,但在输入方面有限。我们需要预处理它,使其成为一组特征向量。它们在学习能力上也有限——它们是一次性学习者。我们只能训练它们一次,并且不能添加更多训练。如果需要更多训练,我们需要从头开始训练这些模型。

经典的机器学习模型在处理复杂结构,如图像的能力上也被认为相当有限。正如我们之前所学的,图像至少有两个不同的维度,并且可以包含三个信息通道——红色、绿色和蓝色。在更复杂的应用中,图像可以包含来自激光雷达或地理空间数据的数据,这些数据可以提供关于图像的元信息。

因此,为了处理图像,需要更复杂的模型。其中之一是 YOLO 模型。由于其准确性和速度之间取得了很好的平衡,YOLO 模型被认为在目标检测领域处于最前沿。

让我们看看如何利用 Hugging Face 中的预训练 YOLO v5 模型。在这里,我想提供我的下一个最佳实践。

最佳实践 #51

从 Hugging Face 或 TensorFlow Hub 使用预训练模型开始。

使用预训练模型有几个优点:

  • 首先,它允许我们将网络作为我们管道的基准。在继续前进并开始训练它之前,我们可以对其进行实验并了解其局限性。

  • 其次,它为我们提供了为现有、经过实际使用验证的模型添加更多训练的可能性,这些模型也被其他人使用过。

  • 最后,它为我们提供了与社区分享我们的模型的可能性,以支持人工智能的道德和负责任的发展。

以下代码片段安装 YoLo 模型并实例化它:

# install YoLo v5 network
!pip install -q -U yolov5
# then we set up the network
import yolov5
# load model
model = yolov5.load('fcakyon/yolov5s-v7.0')
# set model parameters
model.conf = 0.25  # NMS confidence threshold
model.iou = 0.45  # NMS IoU threshold
model.agnostic = False  # NMS class-agnostic
model.multi_label = False  # NMS multiple labels per box
model.max_det = 1000  # maximum number of detections per image

前几行使用 load 函数从指定的源加载 YOLOv5 模型——即 fcakyon/yolov5s-v7.0 ——并将加载的模型分配给变量 model,该变量可用于执行目标检测。model.conf 参数设置了 非极大值抑制NMS)的置信度阈值,该阈值用于过滤掉低于此置信度水平的检测。在这种情况下,它被设置为 0.25,这意味着只有置信度分数高于 0.25 的检测将被考虑。

model.iou 参数设置了 model.agnostic 参数确定 NMS 是否为类别无关。如果设置为 False,NMS 将在抑制过程中考虑类别标签,这意味着如果两个边界框具有相同的坐标但不同的标签,它们将不会被考虑为重复。在这里,它被设置为 Falsemodel.multi_label 参数控制 NMS 是否允许每个边界框有多个标签。如果设置为 False,每个框将被分配一个具有最高置信度分数的单个标签。在这里,它被设置为 False

最后,model.max_det 参数设置了每张图像允许的最大检测数量。在这种情况下,它被设置为 1000,这意味着只有前 1,000 个检测(按置信度分数排序)将被保留。

现在,我们可以执行推理——即使用网络检测对象——但首先,我们必须加载图像:

# and now we prepare the image
from PIL import Image
from torchvision import transforms
# Load and preprocess the image
image = Image.open('./test_image.jpg')

此代码片段使用 PIL 的 Image 模块的 open 函数加载位于 ./test_image.jpg 的图像文件。它创建了一个表示图像的 Image 类实例。

一旦加载了图像,你可以在将其馈送到 YOLOv5 模型进行目标检测之前对其进行各种转换以进行预处理。这可能涉及调整大小、归一化或其他预处理步骤,具体取决于模型的要求:

# perform inference
results = model(image)
# inference with larger input size
results = model(image, size=640)
# inference with test time augmentation
results = model(image, augment=True)
# parse results
predictions = results.pred[0]
boxes = predictions[:, :4] # x1, y1, x2, y2
scores = predictions[:, 4]
categories = predictions[:, 5]
# show detection bounding boxes on image
results.show()

前面的代码片段在第一行执行目标检测,然后绘制图像,以及检测到的对象的边界框。在我们的例子中,这是前面代码片段的结果:

图 9.2 – 图像中检测到的对象

图 9.2 – 图像中检测到的对象

请注意,该模型将汽车识别为卡车,可能是因为汽车后部存在额外的行李。图像来源是 Pixabay。图示表明,目标检测未能正确识别对象。然而,这并不是问题。我们可以使用这个预训练模型并进一步训练它。然而,这将是下一章的主题,所以我们在这里不会涉及。

最佳实践#52

与预训练网络合作,识别它们的局限性,然后在您的数据集上训练网络。

我强烈建议从预训练模型开始使用,然后在您的数据集上训练网络。深度学习模型能够继续训练的能力是我们设计基于机器学习的系统时可以利用的伟大特性。这样,我们就能兼得两者之优——我们的系统可以检测通用对象,同时在检测系统指定的对象方面表现得更好。这种方法通常用于设计汽车系统。

让我们看看其他类型的深度学习系统,这些系统使用预训练并增加了一层复杂性——提示工程。

BERT 和 GPT 模型

BERT 和 GPT 模型使用原始数据作为输入,它们的主要输出是一个预测的单词。这个单词可以在句中预测,也可以在句尾预测。这意味着围绕这些模型设计的产物需要以不同于其他模型的方式处理数据。

图 9*.3* 提供了这种处理方法的概述,重点关注开始时的提示工程和结束时的输出处理。此图显示了位于中心的基于 BERT 或 GPT 架构的机器学习模型。这是一个重要方面,但它只提供了整个系统(或工具)的非常小的一部分。

工具的工作流程从左侧的输入处理开始。对于用户来说,这是一个提示,要求模型执行某些操作,例如:“用 C 语言编写一个反转字符串的函数”。工具将这个提示转换为模型的有用输入——它可以找到与模型输入相似的 C 程序,并在该程序末尾添加<mask>标记,使模型预测输出。

然后,模型进行预测——一次一个标记。工具需要循环输出,直到生成有用的内容。工具需要定义停止标准,并将输出处理成对用户有用的形式:

图 9.3 – 现代深度学习工具设计概述,其中机器学习模型只是工具的一个小(但重要)部分

图 9.3 – 现代深度学习工具设计概述,其中机器学习模型只是工具的一个小(但重要)部分

如果我们将这个提示发送到 ChatGPT,我们会得到以下三部分的响应。首先,我们得到函数的实际代码,该函数用 C 语言反转字符串:

#include <stdio.h>
#include <string.h>
void reverseString(char* str) {
    int length = strlen(str);
    int i, j;
    for (i = 0, j = length - 1; i < j; i++, j--) {
        char temp = str[i];
        str[i] = str[j];
        str[j] = temp;
    }
}
int main() {
    char str[] = "Hello, world!";
    printf("Original string: %s\n", str);
    reverseString(str);
    printf("Reversed string: %s\n", str);
    return 0;
}

这个函数没有生成任何注释,所以 ChatGPT 使用摘要管道以以下方式描述模型:

This code defines a function called reverseString that takes a character array (char*) as input and reverses the string in-place. It uses two pointers, i and j, starting from the beginning and end of the string respectively, and swaps the characters until they meet in the middle.
In the main function, a sample string "Hello, world!" is provided, and the reverseString function is called to reverse it. The original and reversed strings are then printed for verification.

这个说明应该替换代码的文档,这需要添加以符合良好的编程实践。

最后,这个工具为我们提供了一个测试这个函数的代码示例:

Original string: Hello, world!
Reversed string: !dlrow ,olleH

响应的最后部分也是作为提示的一部分生成的。这是因为测试代码通常在数据集中以类似的情况存在。现在,如果我们的提示不仅仅是字符串反转,也许是在不同的编程语言中,比如 C#,我们可能会得到不同的响应结构。例如,如果我们的提示是Write a function that creates 3000 random numbers in C# and prints them to the console,那么响应将只包含该函数的 C#代码,而不是测试代码:

using System;
class Program {
    static void Main(string[] args) {
        const int numNumbers = 3000;
        const int minValue = 0;
        const int maxValue = 100;
        Random random = new Random();
        for (int i = 0; i < numNumbers; i++) {
            int num = random.Next(minValue, maxValue + 1);
            Console.WriteLine(num);
        }
    }
}

也生成了说明,但没有测试这个函数的代码。相反,这个函数被封装为class Program,并且没有main()函数来测试它。

在软件系统中使用语言模型

使用 ChatGPT 等产品很棒,但它们也局限于它们被设计的目的。现在,我们可以使用 Hugging Face 界面从头开始使用这样的模型。在下面的代码示例中,我们可以看到如何使用专门用于特定任务(如识别设计模式)的模型来完成文本——即编写 Singleton 设计模式的签名。这说明了语言模型(包括 GPT-3/4)在底层是如何与文本工作的。

在下面的代码片段中,我们正在从 Hugging Face 库导入模型并实例化它。该模型已经在一系列专门的单一程序上进行了预训练,并通过添加来自 Linux 内核源代码的随机代码作为 C++中单一类代码来合成构建:

# import the model via the huggingface library
from transformers import AutoTokenizer, AutoModelForMaskedLM
# load the tokenizer and the model for the pretrained SingBERTa
tokenizer = AutoTokenizer.from_pretrained('mstaron/SingBERTa')
# load the model
model = AutoModelForMaskedLM.from_pretrained("mstaron/SingBERTa")
# import the feature extraction pipeline
from transformers import pipeline

这段代码从 Hugging Face 的 Transformers 库中导入必要的模块。然后,它加载了预训练的 SingBERTa 的标记器和模型。标记器负责将文本转换为数值标记,而模型是一个专门为掩码语言建模MLM)任务设计的预训练语言模型。它从预训练的 SingBERTa 中加载模型。之后,它从 Transformers 库中导入特征提取管道。特征提取管道使我们能够轻松地从模型中提取上下文化的嵌入。

总体而言,这段代码为我们设置了使用 SingBERTa 模型进行各种自然语言处理任务(如文本分词、MLM 和特征提取)所必需的组件。下面的代码片段正是这样做的——它创建了填充空白的管道。这意味着模型已经准备好预测句子中的下一个单词:

fill_mask = pipeline(
    "fill-mask",
    model="./SingletonBERT",
    tokenizer="./SingletonBERT"
)

我们可以通过使用fill_mask("static Singleton:: <mask>")命令来使用这个管道,这将产生以下输出:

[{'score': 0.9703333973884583, 'token': 74, 'token_str': 'f', 'sequence': 'static Singleton::f'},
{'score': 0.025934329256415367, 'token': 313, 'token_str': ' );', 'sequence': 'static Singleton:: );'},
{'score': 0.0003994493163190782, 'token': 279, 'token_str': '();', 'sequence': 'static Singleton::();'},
{'score': 0.00021698368072975427, 'token': 395, 'token_str': ' instance', 'sequence': 'static Singleton:: instance'},
{'score': 0.00016094298916868865, 'token': 407, 'token_str': ' getInstance', 'sequence': 'static Singleton:: getInstance'}]

前面的输出显示,最佳预测是f标记。这是正确的,因为训练示例使用了f作为添加到 Singleton 类中的函数的名称(例如Singleton::f1())。

如果我们想要扩展这些预测,就像 ChatGPT 的代码生成功能一样,我们需要循环前面的代码,一次生成一个标记,从而填充程序。无法保证程序能够编译,因此后处理基本上只能选择这些结构(从提供的标记列表中),这将导致一段可编译的代码。我们甚至可以添加测试此代码的功能,从而使我们的产品越来越智能,而无需创建更大的模型。

因此,这是本章的最后一个最佳实践。

最佳实践 #53

不要寻找更复杂的模型,而是创建一个更智能的管道。

与一个好的管道一起工作可以使一个好的模型变成一个优秀的软件产品。通过提供正确的提示(用于预测的文本开头),我们可以创建一个对我们产品所满足的使用案例有用的输出。

摘要

在本章中,我们窥见了机器学习模型从内部的样子,至少是从程序员的角度来看。这说明了我们在构建基于机器学习的软件时存在的重大差异。

在经典模型中,我们需要创建大量的预处理管道,以确保模型获得正确的输入。这意味着我们需要确保数据具有正确的属性,并且处于正确的格式;我们需要与输出一起工作,将预测转化为更有用的东西。

在深度学习模型中,数据以更流畅的方式进行预处理。模型可以准备图像和文本。因此,软件工程师的任务是专注于产品和其使用案例,而不是监控概念漂移、数据准备和后处理。

在下一章中,我们将继续探讨训练机器学习模型的示例——既包括经典的,也包括最重要的深度学习模型。

参考文献

  • Staron, M. 和 W. Meding. 在大型软件项目中短期缺陷流入预测的初步评估。在《国际软件工程经验评估会议》(EASE)中。2007 年。

  • Prykhodko, S. 基于归一化变换的回归分析开发软件缺陷预测模型。在《现代应用软件测试问题》(PTTAS-2016)中,研究与实践研讨会摘要,波尔塔瓦,乌克兰。2016 年。

  • Ochodek, M. 等, 第八章 使用机器学习识别违反公司特定编码指南的代码行。在《加速数字化转型:软件中心 10 年》中。2022 年,Springer。* 第 211-251 页。*

  • Ibrahim, D.R.,R. Ghnemat,和 A. Hudaib。使用特征选择和随机森林算法进行软件缺陷预测。在 2017 年国际计算科学新趋势会议(ICTCS)上。2017. IEEE.

  • Ochodek, M.,M. Staron,和 W. Meding, 第九章 SimSAX:基于符号近似方法和软件缺陷流入的项目相似度度量。在加速数字化转型:软件中心 10 年。2022,Springer。 p. 253-283.

  • Phan, V.A.,使用自动编码器和 K-Means 学习拉伸-收缩潜在表示以进行软件缺陷预测。IEEE Access,2022. 10: p. 117827-117835.

  • Staron, M.,等人,机器学习支持持续集成中的代码审查。软件工程人工智能方法,2021: p. 141-167.

  • Li, J.,等人。通过卷积神经网络进行软件缺陷预测。在 2017 年 IEEE 国际软件质量、可靠性和安全性会议(QRS)上。2017. IEEE.

  • Feurer, M.,等人,高效且鲁棒的自动机器学习。神经网络信息处理系统进展, 2015. 28.

  • Feurer, M.,等人,Auto-sklearn 2.0:通过元学习实现免手动的自动机器学习。机器学习研究杂志,2022. 23(1): p. 11936-11996.

  • Redmon, J.,等人。你只看一次:统一、实时目标检测。在 IEEE 计算机视觉和模式识别会议论文集中。2016.

  • Staron, M., 《汽车软件架构》. 2021: Springer.

  • Gamma, E., 等人,设计模式:可重用面向对象软件的元素。1995: Pearson Deutschland GmbH.

第十章:训练和评估经典机器学习系统和神经网络

现代机器学习框架被设计成对程序员友好。Python 编程环境(以及 R)的流行表明,设计、开发和测试机器学习模型可以专注于机器学习任务,而不是编程任务。机器学习模型的开发者可以专注于开发整个系统,而不是算法内部的编程。然而,这也带来了一些负面影响——对模型内部结构和它们是如何被训练、评估和验证的缺乏理解。

在本章中,我将更深入地探讨训练和评估的过程。我们将从不同算法背后的基本理论开始,然后学习它们是如何被训练的。我们将从经典的机器学习模型开始,以决策树为例。然后,我们将逐步转向深度学习,在那里我们将探索密集神经网络和更高级的网络类型。

本章最重要的部分是理解训练/评估算法与测试/验证整个机器学习软件系统之间的区别。我将通过描述机器学习算法作为生产机器学习系统的一部分(或整个机器学习系统的样子)来解释这一点。

在本章中,我们将涵盖以下主要主题:

  • 训练和测试过程

  • 训练经典机器学习模型

  • 训练深度学习模型

  • 误导性结果——数据泄露问题

训练和测试过程

机器学习通过使计算机能够从数据中学习并做出预测或决策,而无需明确编程,从而彻底改变了我们解决复杂问题的方式。机器学习的一个关键方面是训练模型,这涉及到教算法识别数据中的模式和关系。训练机器学习模型的两种基本方法是 model.fit()model.predict()

model.fit() 函数是训练机器学习模型的核心。它是模型从标记数据集中学习以做出准确预测的过程。在训练过程中,模型调整其内部参数以最小化其预测与训练数据中真实标签之间的差异。这种迭代优化过程,通常被称为“学习”,允许模型推广其知识并在未见过的数据上表现良好。

除了训练数据和标签之外,model.fit()函数还接受各种超参数作为参数。这些超参数包括周期数(即模型将遍历整个数据集的次数)、批量大小(在更新参数之前处理的样本数量)和学习率(确定参数更新的步长)。正确调整这些超参数对于确保有效的训练和防止诸如过拟合或欠拟合等问题至关重要。

一旦训练过程完成,训练好的模型就可以用于对新数据做出预测。这就是model.predict()方法发挥作用的地方。给定一个训练好的模型和一组输入数据,model.predict()函数将应用学习到的权重和偏差来生成预测或类别概率。预测输出可以用于各种目的,如分类、回归或异常检测,具体取决于手头问题的性质。

我们在之前的章节中看到了这个界面的例子。现在,是时候了解这个界面底下的内容以及训练过程是如何进行的了。在上一章中,我们将这个过程视为一个黑盒,即程序跳过model.fit()行之后,这个过程就完成了。这是这个过程的基本原理,但不仅如此。这个过程是迭代的,并且取决于正在训练的算法/模型。由于每个模型都有不同的参数,拟合函数可以接受更多的参数。我们甚至可以在实例化模型时添加额外的参数,甚至在训练过程之前。图 10.1 将这个过程呈现为一个灰色框:

图 10.1 – 训练机器学习模型的灰色框

图 10.1 – 训练机器学习模型的灰色框

在我们开始训练过程之前,我们将数据分为训练集和测试集(我们之前已经讨论过)。同时,我们选择我们使用的机器学习模型的参数。这些参数可以是任何东西,从随机森林中的树的数量到神经网络中的迭代次数和批量大小。

训练过程是迭代的,其中模型在数据上训练,内部评估,然后重新训练以找到更适合数据的拟合。在本章中,我们将探讨这种内部训练是如何工作的。

最后,一旦模型经过训练,它就准备好进行测试过程。在测试过程中,我们使用预定义的性能指标来检查模型学习到的模式对于新数据能否得到良好的重现。

训练经典机器学习模型

我们将首先训练一个模型,让我们能够查看其内部。我们将使用 CART 决策树分类器,我们可以可视化训练的实际决策树。我们将使用与上一章相同的数值数据。首先,让我们读取数据并创建训练/测试分割:

# read the file with data using openpyxl
import pandas as pd
# we read the data from the excel file,
# which is the defect data from the ant 1.3 system
dfDataAnt13 = pd.read_excel('./chapter_6_dataset_numerical.xlsx',
                            sheet_name='ant_1_3',
                            index_col=0)
# prepare the dataset
import sklearn.model_selection
X = dfDataAnt13.drop(['Defect'], axis=1)
y = dfDataAnt13.Defect
X_train, X_test, y_train, y_test = \
        sklearn.model_selection.train_test_split(X, y, random_state=42, train_size=0.9)

上述代码使用 pandas 库中的pd.read_excel()函数读取名为'chapter_6_dataset_numerical.xlsx'的 Excel 文件。文件被读取到一个名为dfDataAnt13的 DataFrame 中。sheet_name参数指定了要读取的 Excel 文件中的工作表,而index_col参数将第一列设置为 DataFrame 的索引。

代码为训练机器学习模型准备数据集。通过使用drop()方法从dfDataAnt13 DataFrame 中删除'Defect'列,将独立变量(特征)分配给X变量。通过从dfDataAnt13 DataFrame 中选择'Defect'列,将因变量(目标)分配给y变量。

使用sklearn.model_selection.train_test_split()函数将数据集分为训练集和测试集。Xy变量被分为X_trainX_testy_trainy_test变量。train_size参数设置为0.9,表示 90%的数据将用于训练,剩余的 10%将用于测试。random_state参数设置为42以确保分割的可重复性。

一旦数据准备就绪,我们可以导入决策树库并训练模型:

# now that we have the data prepared
# we import the decision tree classifier and train it
from sklearn.tree import DecisionTreeClassifier
# first we create an empty classifier
decisionTreeModel = DecisionTreeClassifier()
# then we train the classifier
decisionTreeModel.fit(X_train, y_train)
# and we test it for the test set
y_pred_cart = decisionTreeModel.predict(X_test)

上述代码片段从sklearn.tree模块导入DecisionTreeClassifier类。创建了一个空的决策树分类器对象,并将其分配给decisionTreeModel变量。该对象将在之前片段中准备好的数据集上进行训练。在decisionTreeModel对象上调用fit()方法来训练分类器。fit()方法接受训练数据(X_train)和相应的目标值(y_train)作为输入。分类器将学习训练数据中的模式和关系以进行预测。

训练好的决策树分类器用于预测测试数据集(X_test)的目标值。在decisionTreeModel对象上调用predict()方法,并将X_test作为输入。预测的目标值存储在y_pred_cart变量中。预测的模型需要评估,因此让我们评估模型的准确率、精确率和召回率:

# now, let's evaluate the code
from sklearn.metrics import accuracy_score
from sklearn.metrics import recall_score
from sklearn.metrics import precision_score
print(f'Accuracy: {accuracy_score(y_test, y_pred_cart):.2f}')
print(f'Precision: {precision_score(y_test, y_pred_cart, average="weighted"):.2f}, Recall: {recall_score(y_test, y_pred_cart, average="weighted"):.2f}')

这段代码片段生成了以下输出:

Accuracy: 0.83
Precision: 0.94, Recall: 0.83

指标显示模型表现不错。它正确地将测试集中的 83%的数据分类。它对真实正例(更高的精确率)比对真实负例(较低的召回率)更敏感。这意味着它在预测中可能会错过一些缺陷易发模块。然而,决策树模型让我们能够查看模型内部,并探索它从数据中学到的模式。以下代码片段就是这样做的:

from sklearn.tree import export_text
tree_rules = export_text(decisionTreeModel, feature_names=list(X_train.columns))
print(tree_rules)

前面的代码片段以文本形式导出决策树,我们将其打印出来。export_text() 函数接受两个参数——第一个是要可视化的决策树,下一个是特征列表。在我们的情况下,特征列表是数据集的列列表。

在这种情况下,整个决策树相当复杂,但第一个决策路径看起来是这样的:

|--- WMC <= 36.00
|   |--- ExportCoupling <= 1.50
|   |   |--- NOM <= 2.50
|   |   |   |--- NOM <= 1.50
|   |   |   |   |--- class: 0
|   |   |   |--- NOM >  1.50
|   |   |   |   |--- WMC <= 5.50
|   |   |   |   |   |--- class: 0
|   |   |   |   |--- WMC >  5.50
|   |   |   |   |   |--- CBO <= 4.50
|   |   |   |   |   |   |--- class: 1
|   |   |   |   |   |--- CBO >  4.50
|   |   |   |   |   |   |--- class: 0
|   |   |--- NOM >  2.50
|   |   |   |--- class: 0

这个决策路径看起来非常类似于一个大的 if-then 语句,如果我们知道数据中的模式,我们就可以自己编写它。这个模式并不简单,这意味着数据相当复杂。它可能是非线性的,需要复杂的模型来捕捉依赖关系。它也可能需要大量的努力来找到模型性能和其泛化数据能力之间的正确平衡。

因此,这是我处理这类模型的最佳实践。

最佳实践 #54

如果你想要理解你的数值数据,请使用提供可解释性的模型。

在前面的章节中,我提倡使用 AutoML 模型,因为它们稳健且能为我们节省大量寻找正确模块的麻烦。然而,如果我们想更好地理解我们的数据并了解模式,我们可以从决策树等模型开始。它们对数据的洞察为我们提供了关于我们可以从数据中获得什么的良好概述。

作为反例,让我们看看来自同一数据集的另一个模块的数据。让我们读取它并执行分割:

# read the file with data using openpyxl
import pandas as pd
# we read the data from the excel file,
# which is the defect data from the ant 1.3 system
dfDataCamel12 = pd.read_excel('./chapter_6_dataset_numerical.xlsx',
                            sheet_name='camel_1_2',
                            index_col=0)
# prepare the dataset
import sklearn.model_selection
X = dfDataCamel12.drop(['Defect'], axis=1)
y = dfDataCamel12.Defect
X_train, X_test, y_train, y_test = \
        sklearn.model_selection.train_test_split(X, y, random_state=42, train_size=0.9)

现在,让我们为这些数据训练一个新的模型:

# now that we have the data prepared
# we import the decision tree classifier and train it
from sklearn.tree import DecisionTreeClassifier
# first we create an empty classifier
decisionTreeModelCamel = DecisionTreeClassifier()
# then we train the classifier
decisionTreeModelCamel.fit(X_train, y_train)
# and we test it for the test set
y_pred_cart_camel = decisionTreeModel.predict(X_test)

到目前为止,一切顺利——没有错误,没有问题。让我们检查一下模型的表现:

# now, let's evaluate the code
from sklearn.metrics import accuracy_score
from sklearn.metrics import recall_score
from sklearn.metrics import precision_score
print(f'Accuracy: {accuracy_score(y_test, y_pred_cart_camel):.2f}')
print(f'Precision: {precision_score(y_test, y_pred_cart_camel, average="weighted"):.2f}, Recall: {recall_score(y_test, y_pred_cart_camel, average="weighted"):.2f}')

然而,性能并不像之前那么高:

Accuracy: 0.65
Precision: 0.71, Recall: 0.65

现在,让我们打印出树:

from sklearn.tree import export_text
tree_rules = export_text(decisionTreeModel, feature_names=list(X_train.columns))
print(tree_rules)

如我们所见,结果也相当复杂:

|--- WMC >  36.00
|   |--- DCC <= 3.50
|   |   |--- WMC <= 64.50
|   |   |   |--- NOM <= 17.50
|   |   |   |   |--- ImportCoupling <= 7.00
|   |   |   |   |   |--- NOM <= 6.50
|   |   |   |   |   |   |--- class: 0
|   |   |   |   |   |--- NOM >  6.50
|   |   |   |   |   |   |--- CBO <= 4.50
|   |   |   |   |   |   |   |--- class: 0
|   |   |   |   |   |   |--- CBO >  4.50
|   |   |   |   |   |   |   |--- ExportCoupling <= 13.00
|   |   |   |   |   |   |   |   |--- NOM <= 16.50
|   |   |   |   |   |   |   |   |   |--- class: 1
|   |   |   |   |   |   |   |   |--- NOM >  16.50
|   |   |   |   |   |   |   |   |   |--- class: 0
|   |   |   |   |   |   |   |--- ExportCoupling >  13.00
|   |   |   |   |   |   |   |   |--- class: 0
|   |   |   |   |--- ImportCoupling >  7.00
|   |   |   |   |   |--- class: 0
|   |   |   |--- NOM >  17.50
|   |   |   |   |--- class: 1
|   |   |--- WMC >  64.50
|   |   |   |--- class: 0

如果我们看看这个树中的第一个决策和上一个决策,它基于 WMC 特征。WMC 代表 加权方法每类,是 20 世纪 90 年代由 Chidamber 和 Kamerer 提出的经典软件度量之一。该度量捕捉了类的复杂性和大小(以某种方式),因此大类的缺陷倾向性更强——简单地说,如果源代码更多,犯错误的机会就更大。在这个模型的情况下,这要复杂一些,因为模型认识到 WMC 超过 36 的类比其他类更容易出错,除了超过 64.5 的类,这些类不太容易出错。后者也是一个已知现象,即大类的测试也更为困难,因此可能包含未发现的缺陷。

这里是我的下一个最佳实践,关于模型的可解释性。

最佳实践 #55

最好的模型是那些能够捕捉数据中经验现象的模型。

尽管机器学习模型可以捕捉任何类型的依赖关系,但最佳模型是那些能够捕捉逻辑和经验观察的模型。在先前的例子中,模型可以捕捉与类的大小及其易出错性相关的软件工程经验观察。拥有能够捕捉经验关系的模型可以带来更好的产品和可解释的人工智能。

理解训练过程

从软件工程师的角度来看,训练过程相当简单——我们拟合模型,验证它,并使用它。我们检查模型在性能指标方面的好坏。如果模型足够好,并且我们可以解释它,那么我们就围绕它开发整个产品,或者将其用于更大的软件产品中。

当模型没有学习到任何有用的东西时,我们需要了解为什么会出现这种情况,以及是否可能存在另一个可以做到的模型。我们可以使用我们在第六章中学到的可视化技术来探索数据,并使用第四章中的技术清除噪声。

现在,让我们探索决策树模型如何从数据中学习的流程。DecisionTree分类器通过递归地根据训练数据集中特征的值对特征空间进行分区来从提供的数据中学习。它构建一个二叉树,其中每个内部节点代表一个特征和一个基于阈值值的决策规则,每个叶节点代表一个预测的类别或结果。

训练过程分为以下步骤:

  1. 选择最佳特征:分类器评估不同的特征,并确定最佳分离数据为不同类别的特征。这通常是通过不纯度度量或信息增益来完成的,例如基尼不纯度或熵。

  2. 分割数据集:一旦选定了最佳特征,分类器将数据集根据该特征的值分割成两个或更多子集。每个子集代表决策树中的不同分支或路径。

  3. 递归重复过程:上述步骤对决策树的每个子集或分支重复进行,将它们视为单独的数据集。这个过程会继续进行,直到满足停止条件,例如达到最大深度、节点上的最小样本数或其他预定义标准。

  4. 分配类别标签:在决策树的叶节点处,分类器根据该区域样本的多数类别分配类别标签。这意味着在做出预测时,分类器将叶节点中最频繁的类别分配给落入该区域的未见样本。

在学习过程中,DecisionTree分类器旨在找到最佳分割,以最大化类别的分离并最小化每个结果子集中的不纯度。通过根据提供的训练数据递归地根据特征空间进行分区,分类器学习决策规则,使其能够对未见数据做出预测。

需要注意的是,决策树容易过拟合,这意味着它们可以过度记住训练数据,并且对新数据泛化能力不强。例如剪枝、限制最大深度或使用随机森林等集成方法可以帮助减轻过拟合并提高决策树模型的表现。

我们在这本书中已经使用过随机森林分类器,所以这里不会深入细节。尽管随机森林在泛化数据方面表现更好,但与决策树相比,它们是不透明的。我们无法探索模型学到了什么——我们只能探索哪些特征对判决贡献最大。

随机森林和不透明模型

让我们基于与反例中相同的数据训练随机森林分类器,并检查模型是否表现更好,以及模型是否使用与原始反例中DecisionTree分类器相似的特征。

让我们使用以下代码片段实例化、训练和验证模型:

from sklearn.ensemble import RandomForestClassifier
randomForestModel = RandomForestClassifier()
randomForestModel.fit(X_train, y_train)
y_pred_rf = randomForestModel.predict(X_test)

在评估模型后,我们获得了以下性能指标:

Accuracy: 0.62
Precision: 0.63, Recall: 0.62

诚然,这些指标与决策树中的指标不同,但整体性能并没有太大的差异。0.03 的准确度差异是可以忽略不计的。首先,我们可以提取重要特征,重复使用在第五章中介绍过的相同技术:

# now, let's check which of the features are the most important ones
# first we create a dataframe from this list
# then we sort it descending
# and then filter the ones that are not imporatnt
dfImportantFeatures = pd.DataFrame(randomForestModel.feature_importances_, index=X.columns, columns=['importance'])
# sorting values according to their importance
dfImportantFeatures.sort_values(by=['importance'],
                                ascending=False,
                                inplace=True)
# choosing only the ones that are important, skipping
# the features which have importance of 0
dfOnlyImportant = dfImportantFeatures[dfImportantFeatures['importance'] != 0]
# print the results
print(f'All features: {dfImportantFeatures.shape[0]}, but only {dfOnlyImportant.shape[0]} are used in predictions. ')

我们可以通过执行以下代码来可视化决策中使用的特征集:

# we use matplotlib and seaborn to make the plot
import matplotlib.pyplot as plt
import seaborn as sns
# Define size of bar plot
# We make the x axis quite much larger than the y-axis since
# there is a lot of features to visualize
plt.figure(figsize=(40,10))
# plot Searborn bar chart
# we just use the blue color
sns.barplot(y=dfOnlyImportant['importance'],
            x=dfOnlyImportant.index,
            color='steelblue')
# we make the x-labels rotated so that we can fit
# all the features
plt.xticks(rotation=90)
sns.set(font_scale=6)
# add chart labels
plt.title('Importance of features, in descending order')
plt.xlabel('Feature importance')
plt.ylabel('Feature names')

此代码帮助我们理解图 10.2中显示的重要性图表。在这里,WMC(加权方法计数)是最重要的特征。这意味着森林中有许多树使用此指标来做出决策。然而,由于森林是一个集成分类器——它使用投票来做出决策——这意味着在做出最终调用/预测时总是使用多棵树:

图 10.2 – 随机森林分类器的特征重要性图表。

图 10.2 – 随机森林分类器的特征重要性图表。

请注意,该模型比这些特征的线性组合更复杂。此图表展示了不是最佳实践,而是一种最佳经验。因此,我将将其用作最佳实践来展示其重要性。

最佳实践 #56

简单但可解释的模型通常可以很好地捕捉数据。

在我使用不同类型数据的实验过程中,我所学到的经验是,如果有模式,一个简单的模型就能捕捉到它。如果没有模式,或者数据有很多不符合规则的情况,那么即使是最复杂的模型在寻找模式时也会遇到问题。因此,如果你不能解释你的结果,不要将它们用于你的产品中,因为这些结果可能会使产品变得相当无用。

然而,在这个隧道尽头有一线光明。一些模型可以捕捉非常复杂的模式,但它们是透明的——神经网络。

训练深度学习模型

训练密集神经网络涉及多个步骤。首先,我们准备数据。这通常涉及特征缩放、处理缺失值、编码分类变量以及将数据分为训练集和验证集。

然后,我们定义密集神经网络的架构。这包括指定层数、每层的神经元数量、要使用的激活函数以及任何正则化技术,如 dropout 或批量归一化。

一旦定义了模型,我们就需要初始化它。我们根据定义的架构创建神经网络模型的一个实例。这涉及到创建神经网络类的一个实例或使用深度学习库中可用的预定义模型架构。我们还需要定义一个损失函数,该函数量化模型预测输出与实际目标值之间的误差。损失函数的选择取决于问题的性质,例如分类(交叉熵)或回归(均方误差)。

除了损失函数之外,我们还需要一个优化器。优化器算法将在训练过程中更新神经网络的权重。常见的优化器包括随机梯度下降(SGD)、Adam 和 RMSprop。

然后,我们可以训练模型。在这里,我们遍历训练数据多次(整个数据集的遍历)。在每个 epoch(遍历整个数据集)中,执行以下步骤:

  1. 前向传播:我们将一批输入数据输入到模型中,并计算预测输出。

  2. 计算损失:我们使用定义的损失函数将预测输出与实际目标值进行比较,以计算损失。

  3. 反向传播:我们通过反向传播将损失反向传播到网络中,以计算权重相对于损失的梯度。

  4. 更新权重:我们使用优化器根据计算出的梯度来更新神经网络的权重,调整网络参数以最小化损失。

我们对训练数据中的每个批次重复这些步骤,直到所有批次都已被处理。

最后,我们需要执行验证过程,就像在之前的模型中一样。在这里,我们计算一个验证指标(例如准确度或均方误差)来评估模型对未见数据的泛化能力。这有助于我们监控模型的进展并检测过拟合。

一旦模型经过训练和验证,我们就可以在未用于训练或验证的单独测试数据集上评估其性能。在这里,我们计算相关的评估指标来评估模型的准确度、精确度、召回率或其他所需的指标。

因此,让我们为我们的数据集做这件事。首先,我们必须使用以下代码定义模型的架构:

import torch
import torch.nn as nn
import torch.optim as optim
# Define the neural network architecture
class NeuralNetwork(nn.Module):
    def __init__(self, input_size, hidden_size, num_classes):
        super(NeuralNetwork, self).__init__()
        self.fc1 = nn.Linear(input_size, hidden_size)
        self.relu = nn.ReLU()
        self.fc2 = nn.Linear(hidden_size, num_classes)
    def forward(self, x):
        out = self.fc1(x)
        out = self.relu(out)
        out = self.fc2(out)
        return out

在这里,我们定义了一个名为 NeuralNetwork 的类,它是 nn.Module 的子类。这个类代表我们的神经网络模型。它有两个全连接层(fc1fc2),层间使用 ReLU 激活函数。网络看起来就像 图 10.3 中所示的那样:

图 10.3 – 用于预测缺陷的神经网络

图 10.3 – 用于预测缺陷的神经网络。

这个可视化是使用alexlenail.me/NN-SVG/index.html创建的。隐藏层中的神经元数量是 64,但在这个图中,只显示了 16 个,以便使其更易于阅读。网络从 6 个输入神经元开始,然后是隐藏层(中间)的 64 个神经元,最后是两个用于决策类别的神经元。

现在,我们可以定义训练网络的超参数并实例化它:

# Define the hyperparameters
input_size = X_train.shape[1]  # Number of input features
hidden_size = 64              # Number of neurons in the hidden layer
num_classes = 2               # Number of output classes
# Create an instance of the neural network
model = NeuralNetwork(input_size, hidden_size, num_classes)
# Define the loss function and optimizer
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
# Convert the data to PyTorch tensors
X_train_tensor = torch.Tensor(X_train.values)
y_train_tensor = torch.LongTensor(y_train.values)
X_test_tensor = torch.Tensor(X_test.values)
# Training the neural network
num_epochs = 10000
batch_size = 32

在这里,我们创建了一个名为 modelNeuralNetwork 类实例,具有指定的输入大小、隐藏大小和输出类数量,正如我们在第一个代码片段中定义的那样。我们定义了损失函数(交叉熵损失)和优化器(Adam 优化器)来训练模型。然后,使用 torch.Tensor()torch.LongTensor() 将数据转换为 PyTorch 张量。最后,我们表示我们希望在 10,000 个 epoch(迭代)中训练模型,每个迭代包含 32 个元素(数据点):

for epoch in range(num_epochs):
    for I in range(0, len(X_train_tensor), batch_size):
        batch_X = X_train_tensor[i:i+batch_size]
        batch_y = y_train_tensor[i:i+batch_size]
        # Forward pass
        outputs = model(batch_X)
        loss = criterion(outputs, batch_y)
        # Backward and optimize
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
    # Print the loss at the end of each epoch
    if (epoch % 100 == 0):
      print(""Epoch {epoch+1}/{num_epochs}, Loss: {loss.item():.3f"")

现在,我们可以获取测试数据的预测并获取性能指标:

with torch.no_grad():
    model.eval()  # Set the model to evaluation mode
    X_test_tensor = torch.Tensor(X_test.values)
    outputs = model(X_test_tensor)
    _, predicted = torch.max(outputs.data, 1)
    y_pred_nn = predicted.numpy()
# now, let's evaluate the code
from sklearn.metrics import accuracy_score
from sklearn.metrics import recall_score
from sklearn.metrics import precision_score
print(f'Accuracy: {accuracy_score(y_test, y_pred_nn):.2f}')
print(f'Precision: {precision_score(y_test, y_pred_nn, average="weighted"):.2f}, Recall: {recall_score(y_test, y_pred_nn, average="weighted"):.2f}')

性能指标如下:

Accuracy: 0.73
Precision: 0.79, Recall: 0.73

因此,这比之前的模型要好一些,但并不出色。模式根本不存在。我们可以通过增加隐藏层的数量来使网络更大,但这并不会使预测变得更好。

误导性结果 – 数据泄露

在训练过程中,我们使用一组数据,在测试集中使用另一组数据。最佳的训练过程是当这两个数据集是分开的时候。如果它们不是分开的,我们就会遇到一个称为数据泄露问题的情况。这个问题是指我们在训练集和测试集中有相同的数据点。让我们用一个例子来说明这一点。

首先,我们需要创建一个新的分割,其中两个集合中都有一些数据点。我们可以通过使用 split 函数并将 20%的数据点设置为测试集来实现这一点。这意味着至少有 10%的数据点在两个集合中:

X_trainL, X_testL, y_trainL, y_testL = \
        sklearn.model_selection.train_test_split(X, y, random_state=42, train_size=0.8)

现在,我们可以使用相同的代码对这组数据进行预测,然后计算性能指标:

# now, let's evaluate the model on this new data
with torch.no_grad():
    model.eval()  # Set the model to evaluation mode
    X_test_tensor = torch.Tensor(X_testL.values)
    outputs = model(X_test_tensor)
    _, predicted = torch.max(outputs.data, 1)
    y_pred_nn = predicted.numpy()
print(f'Accuracy: {accuracy_score(y_testL, y_pred_nn):.2f}')
print(f'Precision: {precision_score(y_testL, y_pred_nn, average="weighted"):.2f}, Recall: {recall_score(y_testL, y_pred_nn, average="weighted"):.2f}')

结果如下:

Accuracy: 0.85
Precision: 0.86, Recall: 0.85

结果比之前更好。然而,它们之所以更好,仅仅是因为 10%的数据点被用于训练和测试集。这意味着模型的性能比指标所暗示的要差得多。因此,我们得出了我的下一个最佳实践。

最佳实践 #56

总是要确保训练集和测试集中的数据点是分开的。

尽管我们在这里故意犯了这个错误,但在实践中很容易犯这个错误。请注意 split 函数中的random_state=42参数。显式设置它确保了分割的可重复性。然而,如果我们不这样做,我们每次进行分割时都可能会得到不同的分割,从而可能导致数据泄露问题。

当我们处理图像或文本时,数据泄露问题甚至更难发现。仅仅因为一个图像来自两个不同的文件,并不能保证它是不同的。例如,在高速公路上连续拍摄的图像将不同,但不会太不同,如果它们最终出现在测试集和训练集中,我们就会得到数据泄露问题的一个全新维度。

摘要

在本章中,我们讨论了与机器学习和神经网络相关的各种主题。我们解释了如何使用 pandas 库从 Excel 文件中读取数据,并为训练机器学习模型准备数据集。我们探讨了决策树分类器的使用,并展示了如何使用 scikit-learn 训练决策树模型。我们还展示了如何使用训练好的模型进行预测。

然后,我们讨论了如何从决策树分类器切换到随机森林分类器,后者是决策树的集成。我们解释了必要的代码修改,并提供了示例。接下来,我们将重点转向在 PyTorch 中使用密集神经网络。我们描述了创建神经网络架构、训练模型以及使用训练好的模型进行预测的过程。

最后,我们解释了训练密集神经网络所涉及的步骤,包括数据准备、模型架构、初始化模型、定义损失函数和优化器、训练循环、验证、超参数调整和评估。

总体来说,我们涵盖了与机器学习算法相关的一系列主题,包括决策树、随机森林和密集神经网络,以及它们各自的训练过程。

在下一章中,我们将探讨如何训练更先进的机器学习模型——例如自编码器。

参考文献

  • Chidamber, S.R. 和 C.F. Kemerer, 对面向对象设计的度量集。IEEE 软件工程 Transactions, 1994. 20(6): p. 476–493.

第十一章:高级机器学习算法的训练和评估——GPT 和自动编码器

经典的机器学习ML)和神经网络NNs)非常适合处理经典问题——预测、分类和识别。正如我们在上一章所学,训练它们需要适量的数据,并且我们针对特定任务进行训练。然而,在 2010 年代末和 2020 年代初,机器学习(ML)和人工智能AI)的突破是关于完全不同类型的模型——深度学习DL)、生成预训练转换器GPTs)和生成 AIGenAI)。

生成 AI 模型提供了两个优势——它们可以生成新数据,并且可以为我们提供数据的内部表示,该表示捕捉了数据的上下文,并在一定程度上捕捉了其语义。在前几章中,我们看到了如何使用现有模型进行推理和生成简单的文本片段。

在本章中,我们探讨基于 GPT 和双向编码器表示转换器(BERT)模型的生成 AI 模型是如何工作的。这些模型旨在根据它们训练的模式生成新数据。我们还探讨了自动编码器(AEs)的概念,其中我们训练一个 AE 根据先前训练的数据生成新图像。

在本章中,我们将涵盖以下主要内容:

  • 从经典机器学习模型到生成 AI

  • 生成 AI 模型背后的理论——自动编码器(AEs)和转换器

  • Robustly Optimized BERT ApproachRoBERTa)模型的训练和评估

  • 自动编码器(AE)的训练和评估

  • 开发安全笼子以防止模型破坏整个系统

从经典机器学习到生成 AI

经典人工智能,也称为符号人工智能或基于规则的 AI,是该领域最早的思想流派之一。它基于明确编码知识和使用逻辑规则来操纵符号并推导出智能行为的理念。经典人工智能系统旨在遵循预定义的规则和算法,使它们能够以精确和确定性解决定义明确的问题。我们深入探讨经典人工智能的潜在原则,探索其对基于规则的系统、专家系统和逻辑推理的依赖。

相比之下,生成 AI 代表了人工智能发展的范式转变,利用机器学习(ML)和神经网络(NNs)的力量来创建能够生成新内容、识别模式和做出明智决策的智能系统。生成 AI 不是依赖于显式规则和手工知识,而是利用数据驱动的方法从大量信息中学习,并推断模式和关系。我们探讨生成 AI 的核心概念,包括深度学习(DL)、神经网络(NNs)和概率模型,以揭示其创造原创内容并促进创造性问题解决的能力。

生成式人工智能(GenAI)模型的一个例子是 GPT-3 模型。GPT-3 是由 OpenAI 开发的最先进的语言模型。它基于转换器架构。GPT-3 使用一种称为无监督学习(UL)的技术进行训练,这使得它能够生成连贯且上下文相关的文本。

先进模型(AE 和转换器)背后的理论

经典机器学习(ML)模型的一个大局限是访问标注数据。大型神经网络包含数百万(如果不是数十亿)个参数,这意味着它们需要同样数量的标记数据点来正确训练。数据标注,也称为注释,是 ML 中最昂贵的活动,因此标注过程成为了 ML 模型的实际限制。在 2010 年代初,解决这个问题的方法是使用众包。

众包,这是一种集体数据收集的过程(以及其他),意味着我们使用我们服务的用户来标注数据。CAPTCHA 是最突出的例子之一。当我们需要识别图像以登录服务时,会使用 CAPTCHA。当我们引入新图像时,每次用户需要识别这些图像时,我们可以在相对较短的时间内标注大量数据。

然而,这个过程本身存在一个固有的问题。好吧,有几个问题,但最突出的问题是这个过程主要与图像或类似类型的数据一起工作。它也是一个相对有限的过程——我们只能要求用户识别图像,但不能添加语义图,也不能在图像上绘制边界框。我们不能要求用户评估图像或任何其他,稍微复杂一些的任务。

这里引入了更高级的方法——生成式人工智能(GenAI)和如生成对抗网络(GANs)之类的网络。这些网络被设计用来生成数据并学习哪些数据类似于原始数据。这些网络非常强大,并被用于如图像生成等应用;例如,在所谓的“深度伪造”中。

AEs

这样的模型的主要组成部分是自动编码器(AE),它被设计用来学习输入数据的压缩表示(编码),然后从这个压缩表示中重建原始数据(解码)。

自动编码器(AE)的架构(图 11.1.1)由两个主要组件组成:编码器和解码器。编码器接收输入数据并将其映射到一个低维的潜在空间表示,通常被称为编码/嵌入或潜在表示。解码器接收这个编码表示并将其重建为原始输入数据:

图 11.1 – 自动编码器的高级架构

图 11.1 – 自动编码器的高级架构

自动编码器(AE)的目标是最小化重建误差,即输入数据与解码器输出之间的差异。通过这样做,AE 学习在潜在表示中捕获输入数据的最重要特征,从而有效地压缩信息。最有趣的部分是潜在空间或编码。这一部分允许模型在只有几个数字的小向量中学习复杂数据点(例如,图像)的表示。AE 学习的潜在表示可以被视为输入数据的压缩表示或低维嵌入。这种压缩表示可用于各种目的,例如数据可视化、降维、异常检测,或作为其他下游任务的起点。

编码器部分计算潜在向量,解码器部分可以将它扩展成图像。自动编码器有多种类型;最有趣的一种是变分自动编码器VAE),它编码的是可以生成新数据的函数的参数,而不是数据的表示本身。这样,它可以根据分布创建新数据。实际上,它甚至可以通过组合不同的函数来创建完全新的数据类型。

转换器

自然语言处理NLP)任务中,我们通常使用一种略有不同的生成人工智能类型——转换器。转换器彻底改变了机器翻译领域,但已被应用于许多其他任务,包括语言理解和文本生成。

在其核心,转换器采用了一种自注意力机制,允许模型在处理序列中的不同单词或标记时,权衡它们的重要性。这种注意力机制使得模型能够比传统的循环神经网络RNNs)或卷积神经网络CNNs)更有效地捕捉单词之间的长距离依赖关系和上下文关系。

转换器由编码器-解码器结构组成。编码器处理输入序列,如句子,解码器生成输出序列,通常基于输入和目标序列。转换器有两个独特的元素:

  • 多头自注意力(MHSA):一种允许模型同时关注输入序列中不同位置的机制,捕捉不同类型的依赖关系。这是对 RNN 架构的扩展,它能够连接同一层中的神经元,从而捕捉时间依赖关系。

  • 位置编码:为了将位置信息纳入模型,添加了位置编码向量到输入嵌入中。这些位置编码基于标记及其相互之间的相对位置。这种机制允许我们捕捉特定标记的上下文,因此捕捉文本的基本上下文语义。

图 11.2展示了转换器的高级架构:

图 11.2 – Transformer 的高级架构

图 11.2 – Transformer 的高级架构

在这个架构中,自注意力是模型在处理序列中的单词或标记时,权衡不同单词或标记重要性的关键机制。自注意力机制独立应用于输入序列中的每个单词,并有助于捕捉单词之间的上下文关系和依赖。术语指的是并行操作的独立注意力机制。在 Transformer 模型中可以使用多个自注意力头来捕捉不同类型的关系(尽管我们不知道这些关系是什么)。

每个自注意力头通过计算查询表示和键表示之间的注意力分数来操作。这些注意力分数表示序列中每个单词相对于其他单词的重要性或相关性。通过将查询和键表示之间的点积,然后应用 softmax 函数来归一化分数,获得注意力分数。

然后使用注意力分数来权衡值表示。将加权值相加,以获得序列中每个单词的输出表示。

Transformer 中的前馈网络有两个主要作用:特征提取和位置表示。特征提取从自注意力输出中提取高级特征,其方式与我们之前学习过的词嵌入提取非常相似。通过应用非线性变换,模型可以捕捉输入序列中的复杂模式和依赖关系。位置表示确保模型可以学习每个位置的不同变换。它允许模型学习句子的复杂表示,因此捕捉每个单词和句子的更复杂上下文。

Transformer 架构是现代模型如 GPT-3 的基础,GPT-3 是一个预训练的生成 Transformer;也就是说,它已经在大量文本上进行了预训练。然而,它基于 BERT 及其相关模型。

RoBERTa 模型的训练和评估

通常,GPT-3 的训练过程涉及将模型暴露于来自不同来源的大量文本数据,如书籍、文章、网站等。通过分析这些数据中的模式、关系和语言结构,模型学习根据周围上下文预测单词或短语出现的可能性。这种学习目标是通过称为掩码语言模型MLM)的过程实现的,其中随机掩码输入中的某些单词,模型的任务是根据上下文预测正确的单词。

在本章中,我们训练 RoBERTa 模型,这是现在经典的 BERT 模型的一个变体。我们不是使用如书籍和维基百科文章等通用来源,而是使用程序。为了使我们的训练任务更加具体,让我们训练一个能够“理解”来自网络域的代码的模型——WolfSSL,这是一个 SSL 协议的开源实现,用于许多嵌入式软件设备。

一旦训练完成,BERT 模型能够通过利用其学习到的知识和给定提示中提供的上下文来生成文本。当用户提供一个提示或部分句子时,模型会处理输入,并通过基于从训练数据中学习到的上下文概率预测最可能的下一个词来生成响应。

当涉及到 GPT-3(以及类似的)模型时,它是 BERT 模型的扩展。GPT-3 的生成过程涉及 transformer 架构内的多层注意力机制。这些注意力机制允许模型关注输入文本的相关部分,并在不同的单词和短语之间建立联系,确保生成输出的连贯性和上下文性。模型通过在每个步骤中采样或选择最可能的下一个词来生成文本,同时考虑到之前生成的单词。

因此,让我们通过准备训练数据来开始我们的训练过程。首先,我们读取数据集:

from tokenizers import ByteLevelBPETokenizer
paths = ['source_code_wolf_ssl.txt']
print(f'Found {len(paths)} files')
print(f'First file: {paths[0]}')

这为我们提供了原始训练集。在这个集合中,文本文件包含了一个文件中的所有 WolfSSL 协议的源代码。我们不必这样准备,但这样做确实使过程更容易,因为我们只需处理一个源文件。现在,我们可以训练分词器,这与我们在前几章中看到的过程非常相似:

# Initialize a tokenizer
tokenizer = ByteLevelBPETokenizer()
print('Training tokenizer...')
# Customize training
# we use a large vocabulary size, but we could also do with ca. 10_000
tokenizer.train(files=paths,
                vocab_size=52_000,
                min_frequency=2,
                special_tokens=["<s>","<pad>","</s>","<unk>","<mask>",])

第一行初始化ByteLevelBPETokenizer分词器类的实例。这个分词器基于字节对编码BPE)算法的字节级版本,这是一种流行的子词分词方法。我们已在前几章中讨论过它。

下一行打印一条消息,表明分词器训练过程开始。

调用tokenizer.train()函数来训练分词器。训练过程需要几个参数:

  • files=paths: 此参数指定包含要训练分词器的文本数据的输入文件或路径。它期望一个文件路径列表。

  • vocab_size=52_000: 此参数设置词汇表的大小;也就是说,分词器将生成的唯一标记的数量。在这种情况下,分词器将创建一个包含 52,000 个标记的词汇表。

  • min_frequency=2: 此参数指定一个标记必须在训练数据中出现的最小频率,才能包含在词汇表中。低于此阈值的标记将被视为词汇表外OOV)标记。

  • `special_tokens=["","","

一旦训练过程完成,分词器将学习词汇表,并能够使用训练的子词单元对文本进行编码和解码。现在,我们可以使用以下代码保存分词器:

import os
# we give this model a catchy name - wolfBERTa
# because it is a RoBERTa model trained on the WolfSSL source code
token_dir = './wolfBERTa'
if not os.path.exists(token_dir):
  os.makedirs(token_dir)
tokenizer.save_model('wolfBERTa')

我们还使用以下行测试此分词器:tokenizer.encode("int main(int argc, void **argv)").tokens

现在,让我们确保在下一步中分词器与我们的模型可比较。为此,我们需要确保分词器的输出永远不会超过模型可以接受的标记数:

from tokenizers.processors import BertProcessing
# let's make sure that the tokenizer does not provide more tokens than we expect
# we expect 512 tokens, because we will use the BERT model
tokenizer._tokenizer.post_processor = BertProcessing(
    ("</s>", tokenizer.token_to_id("</s>")),
    ("<s>", tokenizer.token_to_id("<s>")),
)
tokenizer.enable_truncation(max_length=512)

现在,我们可以开始准备模型。我们通过从 HuggingFace hub 导入预定义的类来完成此操作:

import the RoBERTa configuration
from transformers import RobertaConfig
# initialize the configuration
# please note that the vocab size is the same as the one in the tokenizer.
# if it is not, we could get exceptions that the model and the tokenizer are not compatible
config = RobertaConfig(
    vocab_size=52_000,
    max_position_embeddings=514,
    num_attention_heads=12,
    num_hidden_layers=6,
    type_vocab_size=1,
)

第一行,from transformers import RobertaConfig,从transformers库中导入RobertaConfig类。RobertaConfig类用于配置 RoBERTa 模型。接下来,代码初始化 RoBERTa 模型的配置。传递给RobertaConfig构造函数的参数如下:

  • vocab_size=52_000: 此参数设置 RoBERTa 模型使用的词汇表大小。它应与分词器训练期间使用的词汇表大小相匹配。在这种情况下,分词器和模型都具有 52,000 的词汇表大小,确保它们兼容。

  • max_position_embeddings=514: 此参数设置 RoBERTa 模型可以处理的最大序列长度。它定义了模型可以处理的序列中的最大标记数。较长的序列可能需要截断或分成更小的段。请注意,输入是 514,而不是分词器输出的 512。这是由于我们从起始和结束标记中留出了位置。

  • num_attention_heads=12: 此参数设置 RoBERTa 模型中多头注意力MHA)机制中的注意力头数量。注意力头允许模型同时关注输入序列的不同部分。

  • num_hidden_layers=6: 此参数设置 RoBERTa 模型中的隐藏层数量。这些层包含模型的可学习参数,并负责处理输入数据。

  • type_vocab_size=1: 此参数设置标记类型词汇表的大小。在 RoBERTa 等不使用标记类型(也称为段)嵌入的模型中,此值通常设置为 1。

配置对象 config 存储了所有这些设置,将在初始化实际的 RoBERTa 模型时使用。与分词器具有相同的配置参数确保了模型和分词器是兼容的,并且可以一起使用来正确处理文本数据。

值得注意的是,与拥有 1750 亿个参数的 GPT-3 相比,这个模型相当小,它只有(只有)8500 万个参数。然而,它可以在配备中等性能 GPU 的笔记本电脑上训练(任何具有 6GB VRAM 的 NVIDIA GPU 都可以)。尽管如此,该模型仍然比 2017 年的原始 BERT 模型大得多,后者只有六个注意力头和数百万个参数。

模型创建完成后,我们需要初始化它:

# Initializing a Model From Scratch
from transformers import RobertaForMaskedLM
# initialize the model
model = RobertaForMaskedLM(config=config)
# let's print the number of parameters in the model
print(model.num_parameters())
# let's print the model
print(model)

最后两行打印出模型中的参数数量(略超过 8500 万)以及模型本身。该模型的输出相当大,因此我们在此不展示。

现在模型已经准备好了,我们需要回到数据集并为其准备训练。最简单的方法是重用之前训练好的分词器,通过从文件夹中读取它,但需要更改分词器的类别,以便它适合模型:

from transformers import RobertaTokenizer
# initialize the tokenizer from the file
tokenizer = RobertaTokenizer.from_pretrained("./wolfBERTa", max_length=512)

完成这些操作后,我们可以读取数据集:

from datasets import load_dataset
new_dataset = load_dataset("text", data_files='./source_code_wolf_ssl.txt')

之前的代码片段读取了我们用于训练分词器的相同数据集。现在,我们将使用分词器将数据集转换成一组标记:

tokenized_dataset = new_dataset.map(lambda x: tokenizer(x["text"]), num_proc=8)

这需要一点时间,但它也让我们有机会反思这个代码利用了所谓的 map-reduce 算法,该算法在 2010 年代初大数据概念非常流行时成为了处理大型文件的黄金标准。是 map() 函数利用了该算法。

现在,我们需要通过创建所谓的掩码输入来准备数据集以进行训练。掩码输入是一组句子,其中单词被掩码标记(在我们的例子中是 <mask>)所替换。它可以看起来像 图 11.3 中的示例:

图 11.3 – MLMs 的掩码输入

图 11.3 – MLMs 的掩码输入

很容易猜测 <mask> 标记可以出现在任何位置,并且为了模型能够真正学习掩码标记的上下文,它应该在相似位置出现多次。手动操作会非常繁琐,因此 HuggingFace 库为此提供了一个专门的类 – DataCollatorForLanguageModeling。以下代码演示了如何实例化该类以及如何使用其参数:

from transformers import DataCollatorForLanguageModeling
data_collator = DataCollatorForLanguageModeling(
    tokenizer=tokenizer, mlm=True, mlm_probability=0.15
)

from transformers import DataCollatorForLanguageModeling 这一行导入了 DataCollatorForLanguageModeling 类,该类用于准备语言建模任务的数据。代码初始化了一个名为 data_collatorDataCollatorForLanguageModeling 对象。此对象接受多个参数:

  • tokenizer=tokenizer: 此参数指定用于编码和解码文本数据时要使用的标记器。它期望一个tokenizer对象的实例。在这种情况下,似乎tokenizer对象已被预先定义并分配给tokenizer变量。

  • mlm=True: 此参数指示语言建模任务是 MLM 任务。

  • mlm_probability=0.15: 此参数设置在输入文本中掩码标记的概率。每个标记在数据准备期间有 15%的概率被掩码。

data_collator对象现在已准备好用于准备语言建模任务的数据。它负责诸如标记化和掩码输入数据等任务,以确保与 RoBERTa 模型兼容。现在,我们可以实例化另一个辅助类——Trainer——它管理 MLM 模型的训练过程:

from transformers import Trainer, TrainingArguments
training_args = TrainingArguments(
    output_dir="./wolfBERTa",
    overwrite_output_dir=True,
    num_train_epochs=10,
    per_device_train_batch_size=32,
    save_steps=10_000,
    save_total_limit=2,
)
trainer = Trainer(
    model=model,
    args=training_args,
    data_collator=data_collator,
    train_dataset=tokenized_dataset['train'],
)

from transformers import Trainer, TrainingArguments这一行从transformers库中导入了Trainer类和TrainingArguments类。然后它初始化了一个TrainingArguments对象,training_args。此对象接受多个参数以配置训练过程:

  • output_dir="./wolfBERTa": 此参数指定训练模型和其他训练工件将被保存的目录。

  • overwrite_output_dir=True: 此参数确定是否在存在的情况下覆盖output_dir。如果设置为True,它将覆盖目录。

  • num_train_epochs=10: 此参数设置训练的轮数;即在训练过程中训练数据将被迭代的次数。在我们的例子中,只需要几轮就足够了,例如 10 轮。训练这些模型需要花费很多时间,这就是为什么我们选择较小的轮数。

  • per_device_train_batch_size=32: 此参数设置每个 GPU 的训练批大小。它确定在每次训练步骤中并行处理多少个训练示例。如果您 GPU 的 VRAM 不多,请减少此数值。

  • save_steps=10_000: 此参数指定在保存模型检查点之前要进行的训练步骤数。

  • save_total_limit=2: 此参数限制保存检查点的总数。如果超过限制,将删除较旧的检查点。

初始化训练参数后,代码使用以下参数初始化一个Trainer对象:

  • model=model: 此参数指定要训练的模型。在这种情况下,从我们之前的步骤中预初始化的 RoBERTa 模型被分配给模型变量。

  • args=training_args: 此参数指定训练参数,这是我们之前步骤中准备的。

  • data_collator=data_collator: 此参数指定在训练期间要使用的数据合并器。此对象已在我们的代码中预先准备。

  • train_dataset=tokenized_dataset['train']:此参数指定了训练数据集。看起来已经准备并存储在一个名为 tokenized_dataset 的字典中的标记化数据集,并且该数据集的训练部分被分配给了 train_dataset。在我们的案例中,因为我们没有定义训练-测试分割,所以它采用了整个数据集。

Trainer 对象现在已准备好使用指定的训练参数、数据收集器和训练数据集来训练 RoBERTa 模型。我们只需简单地编写 trainer.train() 即可。

一旦模型完成训练,我们可以使用以下命令保存它:trainer.save_model("./wolfBERTa")。之后,我们可以像在 第十章 中学习的那样使用该模型。

训练模型需要一段时间;在 NVIDIA 4090 这样的消费级 GPU 上,10 个周期的训练可能需要大约一天时间,但如果我们想使用更大的数据集或更多的周期,可能需要更长的时间。我不建议在没有 GPU 的计算机上执行此代码,因为它比在 GPU 上慢约 5-10 倍。因此,我的下一个最佳实践是。

最佳实践 #57

使用 NVIDIA Compute Unified Device Architecture (CUDA;加速计算)来训练如 BERT、GPT-3 和 AEs 等高级模型。

对于经典机器学习,甚至对于简单的神经网络,现代 CPU 已经足够。计算量很大,但并不极端。然而,当涉及到训练 BERT 模型、AEs 以及类似模型时,我们需要加速来处理张量(向量)以及一次性在整个向量上执行计算。CUDA 是 NVIDIA 的加速框架。它允许开发者利用 NVIDIA GPU 的强大能力来加速计算任务,包括深度学习模型的训练。它提供了一些好处:

  • GPU 并行处理,旨在同时处理许多并行计算。深度学习模型,尤其是像 RoBERTa 这样的大型模型,包含数百万甚至数十亿个参数。训练这些模型涉及到对这些参数执行大量的数学运算,如矩阵乘法和卷积。CUDA 使得这些计算可以在 GPU 的数千个核心上并行化,与传统的 CPU 相比,大大加快了训练过程。

  • 针对 PyTorch 或 TensorFlow 优化的张量操作,这些操作旨在与 CUDA 无缝工作。这些框架提供了 GPU 加速库,实现了专门为 GPU 设计的优化张量操作。张量是多维数组,用于在深度学习模型中存储和处理数据。有了 CUDA,这些张量操作可以在 GPU 上高效执行,利用其高内存带宽和并行处理能力。

  • 高内存带宽,这使数据能够以更快的速度在 GPU 内存之间传输,从而在训练期间实现更快的数据处理。深度学习模型通常需要大量数据以批量形式加载和处理。CUDA 允许这些批次在 GPU 上有效地传输和处理,从而减少训练时间。

通过利用 CUDA,深度学习框架可以有效地利用 NVIDIA GPU 的并行计算能力和优化操作,从而显著加速大规模模型如 RoBERTa 的训练过程。

AE 的训练和评估

我们在讨论图像特征工程过程时提到了 AEs,见 第七章。然而,AEs 的用途远不止图像特征提取。它们的一个主要方面是能够重新创建图像。这意味着我们可以根据图像在潜在空间中的位置创建图像。

因此,让我们为在机器学习(ML)中相当标准的 Fashion MNIST 数据集训练 AE 模型。我们在前面的章节中看到了数据集的样子。我们通过以下代码片段准备数据开始我们的训练:

# Transforms images to a PyTorch Tensor
tensor_transform = transforms.ToTensor()
# Download the Fashion MNIST Dataset
dataset = datasets.FashionMNIST(root = "./data",
                         train = True,
                         download = True,
                         transform = tensor_transform)
# DataLoader is used to load the dataset
# for training
loader = torch.utils.data.DataLoader(dataset = dataset,
                                     batch_size = 32,
                                     shuffle = True)

它从 PyTorch 库中导入必要的模块。

它使用 transforms.ToTensor() 定义了一个名为 tensor_transform 的转换。这个转换用于将数据集中的图像转换为 PyTorch 张量。

代码片段使用 datasets.FashionMNIST() 函数下载数据集。train 参数设置为 True,表示下载的数据集用于训练目的。download 参数设置为 True,以自动下载数据集,如果它尚未存在于指定的目录中。

由于我们使用具有加速计算的 PyTorch 框架,我们需要确保图像被转换成张量。transform 参数设置为 tensor_transform,这是在代码片段的第一行定义的转换器。

然后,我们创建一个 DataLoader 对象,用于批量加载数据集进行训练。dataset 参数设置为之前下载的数据集。batch_size 参数设置为 32,表示数据集的每一批次将包含 32 张图像。

shuffle 参数设置为 True,以在每个训练周期的样本顺序中打乱,确保随机化并减少训练过程中的任何潜在偏差。

一旦我们准备好了数据集,我们就可以创建我们的 AE,具体做法如下:

# Creating a PyTorch class
# 28*28 ==> 9 ==> 28*28
class AE(torch.nn.Module):
    def __init__(self):
        super().__init__()
        # Building an linear encoder with Linear
        # layer followed by Relu activation function
        # 784 ==> 9
        self.encoder = torch.nn.Sequential(
            torch.nn.Linear(28 * 28, 128),
            torch.nn.ReLU(),
            torch.nn.Linear(128, 64),
            torch.nn.ReLU(),
            torch.nn.Linear(64, 36),
            torch.nn.ReLU(),
            torch.nn.Linear(36, 18),
            torch.nn.ReLU(),
            torch.nn.Linear(18, 9)
        )
        # Building an linear decoder with Linear
        # layer followed by Relu activation function
        # The Sigmoid activation function
        # outputs the value between 0 and 1
        # 9 ==> 784
        self.decoder = torch.nn.Sequential(
            torch.nn.Linear(9, 18),
            torch.nn.ReLU(),
            torch.nn.Linear(18, 36),
            torch.nn.ReLU(),
            torch.nn.Linear(36, 64),
            torch.nn.ReLU(),
            torch.nn.Linear(64, 128),
            torch.nn.ReLU(),
            torch.nn.Linear(128, 28 * 28),
            torch.nn.Sigmoid()
        )
    def forward(self, x):
        encoded = self.encoder(x)
        decoded = self.decoder(encoded)
        return decoded

首先,我们定义一个名为 AE 的类,它继承自 torch.nn.Module 类,这是 PyTorch 中所有神经网络模块的基类。super().__init__() 行确保调用基类(torch.nn.Module)的初始化。由于 AEs 是一种具有反向传播学习的特殊神经网络类,我们可以从库中继承很多基本功能。

然后,我们定义 AE 的编码器部分。编码器由几个具有 ReLU 激活函数的线性(全连接)层组成。每个torch.nn.Linear层代表输入数据的线性变换,后面跟着一个激活函数。在这种情况下,输入大小为 28 * 28(这对应于 Fashion MNIST 数据集中图像的维度),输出大小逐渐减小,直到达到 9,这是我们潜在向量的大小。

然后,我们定义 AE 的解码器部分。解码器负责从编码表示中重建输入数据。它由几个具有 ReLU 激活函数的线性层组成,后面跟着一个具有 sigmoid 激活函数的最终线性层。解码器的输入大小为 9,这对应于编码器瓶颈中潜在向量空间的大小。输出大小为 28 * 28,这与原始输入数据的维度相匹配。

forward方法定义了 AE 的前向传递。它接受一个x输入,并通过编码器传递以获得编码表示。然后,它通过解码器将编码表示传递以重建输入数据。重建的输出作为结果返回。我们现在可以实例化我们的 AE:

# Model Initialization
model = AE()
# Validation using MSE Loss function
loss_function = torch.nn.MSELoss()
# Using an Adam Optimizer with lr = 0.1
optimizer = torch.optim.Adam(model.parameters(),
                             lr = 1e-1,
                             weight_decay = 1e-8)

在此代码中,我们首先将我们的 AE 实例化为我们自己的模型。然后,我们创建了一个由 PyTorch 提供的均方误差MSE)损失函数的实例。MSE 是回归任务中常用的损失函数。我们需要它来计算预测值和目标值之间的平均平方差异——这些目标值是我们数据集中的单个像素,提供了衡量模型性能好坏的指标。图 11.4显示了学习函数在训练 AE 过程中的作用:

图 11.4 – AE 训练过程中的损失函数(均方误差)

图 11.4 – AE 训练过程中的损失函数(均方误差)

然后,我们初始化用于在训练过程中更新模型参数的优化器。在这种情况下,代码创建了一个 Adam 优化器,这是一种用于训练神经网络(NNs)的流行优化算法。它接受三个重要参数:

  • model.parameters():这指定了将要优化的参数。在这种情况下,它包括我们之前创建的模型(自动编码器,AE)的所有参数。

  • lr=1e-1:这设置了学习率,它决定了优化器更新参数的步长大小。较高的学习率可能导致更快收敛,但可能风险超过最佳解,而较低的学习率可能收敛较慢,但可能具有更好的精度。

  • weight_decay=1e-8:此参数向优化器添加一个权重衰减正则化项。权重衰减通过向损失函数添加一个惩罚项来防止过拟合,该惩罚项会阻止权重过大。1e-8的值表示权重衰减系数。

使用此代码,我们现在有一个自动编码器(AE)的实例用于训练。现在,我们可以开始训练过程。我们训练模型 10 个 epoch,如果需要的话,可以尝试更多:

epochs = 10
outputs = []
losses = []
for epoch in range(epochs):
    for (image, _) in loader:
      # Reshaping the image to (-1, 784)
      image = image.reshape(-1, 28*28)
      # Output of Autoencoder
      reconstructed = model(image)
      # Calculating the loss function
      loss = loss_function(reconstructed, image)
      # The gradients are set to zero,
      # the gradient is computed and stored.
      # .step() performs parameter update
      optimizer.zero_grad()
      loss.backward()
      optimizer.step()
      # Storing the losses in a list for plotting
      losses.append(loss)
    outputs.append((epochs, image, reconstructed))

我们首先遍历指定的 epoch 数量进行训练。在每个 epoch 中,我们遍历加载器,它提供图像数据批次及其相应的标签。我们不使用标签,因为 AE 是一个用于重建图像的网络,而不是学习图像显示的内容——从这个意义上说,它是一个无监督模型。

对于每张图像,我们通过将原始形状为(batch_size, 28, 28)的输入图像数据展平,形成一个形状为(batch_size, 784)的 2D 张量,其中每一行代表一个展平的图像。展平图像是在我们将像素的每一行连接起来创建一个大型向量时创建的。这是必需的,因为图像是二维的,而我们的张量输入需要是一维的。

然后,我们使用reconstructed = model(image)获取重建图像。一旦我们得到重建图像,我们可以计算均方误差(MSE)损失函数,并使用该信息来管理学习的下一步(optimizer.zero_grad())。在最后一行,我们将此信息添加到每次迭代的损失列表中,以便我们可以创建学习图。我们通过以下代码片段来完成:

# Defining the Plot Style
plt.style.use('seaborn')
plt.xlabel('Iterations')
plt.ylabel('Loss')
# Convert the list to a PyTorch tensor
losses_tensor = torch.tensor(losses)
plt.plot(losses_tensor.detach().numpy()[::-1])

这导致了学习图,如图 11.5所示:

图 11.5 – 从训练我们的 AE 得到的学习率图

图 11.5 – 从训练我们的 AE 得到的学习率图

学习率图显示 AE 还不是很好,我们应该再训练一段时间。然而,我们总是可以检查重建图像的外观。我们可以使用以下代码来做这件事:

for i, item in enumerate(image):
  # Reshape the array for plotting
  item = item.reshape(-1, 28, 28)
  plt.imshow(item[0])

代码生成了如图 11.6所示的输出:

图 11.6 – 我们 AE 重建的图像

图 11.6 – 我们 AE 重建的图像

尽管学习率是 OK 的,我们仍然可以从我们的 AE 中获得非常好的结果。

最佳实践#58

除了监控损失,确保可视化生成的实际结果。

监控损失函数是理解 AE 何时稳定的好方法。然而,仅仅损失函数是不够的。我通常绘制实际输出以了解 AE 是否已经正确训练。

最后,我们可以使用此代码可视化学习过程:

yhat = model(image[0])
make_dot(yhat,
         params=dict(list(model.named_parameters())),
         show_attrs=True,
         show_saved=True)

此代码可视化整个网络的学习过程。它创建了一个大图像,我们只能显示其中的一小部分。图 11.7显示了这一部分:

图 11.7 – 训练 AE 的前三个步骤,以 AE 架构的形式可视化

图 11.7 – 训练 AE 的前三个步骤,以 AE 架构的形式可视化

我们甚至可以使用以下代码以文本形式可视化整个架构:

from torchsummary import summary
summary(model, (1, 28 * 28))

这导致了以下模型:

----------------------------------------------------------------
        Layer (type)               Output Shape         Param #
================================================================
            Linear-1               [-1, 1, 128]         100,480
              ReLU-2               [-1, 1, 128]               0
            Linear-3                [-1, 1, 64]           8,256
              ReLU-4                [-1, 1, 64]               0
            Linear-5                [-1, 1, 36]           2,340
              ReLU-6                [-1, 1, 36]               0
            Linear-7                [-1, 1, 18]             666
              ReLU-8                [-1, 1, 18]               0
            Linear-9                 [-1, 1, 9]             171
           Linear-10                [-1, 1, 18]             180
             ReLU-11                [-1, 1, 18]               0
           Linear-12                [-1, 1, 36]             684
             ReLU-13                [-1, 1, 36]               0
           Linear-14                [-1, 1, 64]           2,368
             ReLU-15                [-1, 1, 64]               0
           Linear-16               [-1, 1, 128]           8,320
             ReLU-17               [-1, 1, 128]               0
           Linear-18               [-1, 1, 784]         101,136
          Sigmoid-19               [-1, 1, 784]               0
================================================================
Total params: 224,601
Trainable params: 224,601
Non-trainable params: 0
----------------------------------------------------------------
Input size (MB): 0.00
Forward/backward pass size (MB): 0.02
Params size (MB): 0.86
Estimated Total Size (MB): 0.88
----------------------------------------------------------------

瓶颈层用粗体表示,以说明编码和解码部分是如何相互连接的。

开发安全笼以防止模型破坏整个系统

随着像 MLM 和 AE 这样的 GenAI 系统创建新的内容,存在它们生成的内容可能会破坏整个软件系统或变得不道德的风险。

因此,软件工程师经常使用安全笼的概念来保护模型本身免受不适当的输入和输出。对于像 RoBERTa 这样的 MLM,这可能是一个简单的预处理程序,用于检查生成的内容是否存在问题。从概念上讲,这如图 11*.8* 所示:

图 11.8 – MLM 的安全笼概念

图 11.8 – MLM 的安全笼概念

wolfBERTa 模型的例子中,这可能意味着我们检查生成的代码是否不包含网络安全漏洞,这些漏洞可能允许黑客接管我们的系统。这意味着由 wolfBERTa 模型生成的所有程序都应该使用 SonarQube 或 CodeSonar 等工具进行检查,以查找网络安全漏洞,因此我的下一个最佳实践。

最佳实践 #59

检查 GenAI 模型的输出,以确保它不会破坏整个系统或提供不道德的回应。

我建议创建这样的安全笼,应从系统的需求开始。第一步是了解系统将要做什么,以及这项任务涉及哪些危险和风险。安全笼的输出处理器应确保这些危险情况不会发生,并且得到妥善处理。

一旦我们了解了如何预防危险,我们就可以转向思考如何在语言模型层面上预防这些风险。例如,当我们训练模型时,我们可以选择已知是安全的且不包含安全漏洞的代码。尽管这并不能保证模型生成的代码是安全的,但它确实降低了风险。

摘要

在本章中,我们学习了如何训练高级模型,并看到它们的训练并不比训练第十章中描述的经典 ML 模型更困难。尽管我们训练的模型比第十章中的模型复杂得多,但我们可以使用相同的原则,并将此类活动扩展到训练更复杂的模型。

我们专注于以 BERT 模型(基础 GPT 模型)和 AEs 形式存在的 GenAI。训练这些模型并不困难,我们不需要巨大的计算能力来训练它们。我们的wolfBERTa模型大约有 8000 万个参数,这看起来很多,但真正优秀的模型,如 GPT-3,有数十亿个参数——GPT-3 有 1750 亿个参数,NVIDIA Turing 有超过 3500 亿个参数,而 GPT-4 是 GPT-3 的 1000 倍大。训练过程是相同的,但我们需要超级计算架构来训练这些模型。

我们还了解到,这些模型只是更大软件系统的一部分。在下一章中,我们将学习如何创建这样一个更大的系统。

参考文献

  • Kratsch, W. 等人,《Machine learning in business process monitoring: a comparison of deep learning and classical approaches used for outcome prediction. Business & Information Systems Engineering》,2021 年,第 63 卷: p. 261-276.

  • Vaswani, A. 等人,《Attention is all you need. Advances in neural information processing systems》,2017 年,第 30 卷。

  • Aggarwal, A.,M. Mittal,和 G. Battineni,《Generative adversarial network: An overview of theory and applications. International Journal of Information Management Data Insights》,2021 年,第 1 卷: p. 100004.

  • Creswell, A. 等人,《Generative adversarial networks: An overview. IEEE signal processing magazine》,2018 年,第 35 卷(第 1 期): p. 53-65.

第十二章:设计机器学习流水线(MLOps)及其测试

MLOps,即机器学习(ML)运维,是一套旨在简化机器学习模型在生产环境中部署、管理和监控的实践和技术。它借鉴了 DevOps(开发和运维)方法的概念,将其适应机器学习所面临的独特挑战。

MLOps 的主要目标是弥合数据科学团队和运维团队之间的差距,促进协作,并确保机器学习项目能够有效地和可靠地在规模上部署。MLOps 有助于自动化和优化整个机器学习生命周期,从模型开发到部署和维护,从而提高生产中机器学习系统的效率和效果。

在本章中,我们学习如何在实践中设计和操作机器学习系统。本章展示了如何将流水线转化为软件系统,重点关注在 Hugging Face 上测试机器学习流水线和它们的部署。

在本章中,我们将介绍以下主要主题:

  • 什么是机器学习流水线

  • 机器学习流水线 – 如何在实际系统中使用机器学习

  • 基于原始数据的流水线

  • 基于特征的流水线

  • 机器学习流水线的测试

  • 监控运行时的机器学习系统

什么是机器学习流水线

毫无疑问,在过去的几年里,机器学习领域取得了显著的进步,颠覆了行业并赋予了创新应用以力量。随着对更复杂和精确的模型的需求增长,开发和有效部署它们的复杂性也在增加。机器学习系统的工业应用需要对这些基于机器学习的系统进行更严格测试和验证。为了应对这些挑战,机器学习流水线的概念应运而生,成为简化整个机器学习开发过程的关键框架,从数据预处理和特征工程到模型训练和部署。本章探讨了 MLOps 在尖端深度学习DL)模型如生成预训练转换器GPT)和传统经典机器学习模型中的应用。

我们首先探讨机器学习流水线的潜在概念,强调其在组织机器学习工作流程和促进数据科学家与工程师之间协作的重要性。我们综合了前几章中提出的许多知识——数据质量评估、模型推理和监控。

接下来,我们将讨论构建 GPT 模型及其类似模型的流水线的独特特性和考虑因素,利用它们的预训练特性来处理广泛的语言任务。我们探讨了在特定领域数据上微调 GPT 模型的复杂性以及将它们纳入生产系统的挑战。

在探索了 GPT 管道之后,我们将注意力转向经典机器学习模型,检查特征工程过程及其在从原始数据中提取相关信息中的作用。我们深入研究传统机器学习算法的多样化领域,了解何时使用每种方法,以及在不同场景中的权衡。

最后,我们展示了如何测试机器学习管道,并强调模型评估和验证在评估性能和确保生产环境中的鲁棒性方面的重要性。此外,我们探讨了模型监控和维护的策略,以防止概念漂移并保证持续的性能改进。

机器学习管道

机器学习(ML)管道是一个系统化和自动化的过程,它组织了机器学习工作流程的各个阶段。它包括准备数据、训练机器学习模型、评估其性能以及将其部署到实际应用中的步骤。机器学习管道的主要目标是简化端到端的机器学习过程,使其更加高效、可重复和可扩展。

机器学习管道通常包括以下基本组件:

  • 数据收集、预处理和整理:在这个初始阶段,从各种来源收集相关数据,并准备用于模型训练。数据预处理涉及清理、转换和归一化数据,以确保数据适合机器学习算法。

  • 特征工程和选择:特征工程涉及从原始数据中选择和创建有助于模型学习模式和做出准确预测的相关特征(输入变量)。适当的特征选择对于提高模型性能和减少计算开销至关重要。

  • 模型选择和训练:在这个阶段,选择一个或多个机器学习算法,并在准备好的数据上训练模型。模型训练涉及学习数据中的潜在模式和关系,以进行预测或分类。

  • 模型评估和验证:使用准确率、精确率、召回率、F1 分数等指标来评估训练好的模型在未见数据上的性能。通常使用交叉验证技术来确保模型的一般化能力。

  • 超参数调整:许多机器学习算法都有超参数,这些是可以调整的参数,控制模型的行为。超参数调整涉及找到这些参数的最佳值,以提高模型性能。

  • 模型部署:一旦模型经过训练和验证,它就会被部署到生产环境中,在那里它可以在新的、未见过的数据上进行预测。模型部署可能涉及将模型集成到现有的应用程序或系统中。

  • 模型监控和维护:部署后,持续监控模型的性能,以检测任何性能问题或漂移。定期的维护可能包括使用新数据重新训练模型,以确保其保持准确性和时效性。

机器学习管道为管理机器学习项目的复杂性提供了一个结构化框架,使数据科学家和工程师能够更有效地协作,并确保模型可以可靠且高效地开发和部署。它促进了可重复性、可扩展性和实验的简便性,促进了高质量机器学习解决方案的开发。图 12.1展示了机器学习管道的概念模型,我们在第二章中介绍了它。

图 12.1 – 机器学习管道:概念概述

图 12.1 – 机器学习管道:概念概述

我们在之前的章节中介绍了蓝色阴影元素的要素,在这里,我们主要关注尚未涉及的部分。然而,在我们深入探讨这个管道的技术要素之前,让我们先介绍 MLOps 的概念。

MLOps 的要素

MLOps 的主要目标是弥合数据科学和运维团队之间的差距,因此 MLOps 自动化并优化了整个机器学习生命周期,从模型开发到部署和维护,从而提高了生产中机器学习系统的效率和效果。

MLOps 中的关键组件和实践包括:

  • 版本控制:应用版本控制系统(VCSs)如 Git 来管理和跟踪机器学习代码、数据集和模型版本的变更。这使协作、可重复性和模型改进的跟踪变得容易。

  • 持续集成和持续部署(CI/CD):利用 CI/CD 管道来自动化机器学习模型的测试、集成和部署。这有助于确保代码库的更改能够无缝部署到生产中,同时保持高质量标准。

  • 模型打包:为机器学习模型创建标准化的、可重复的、可共享的容器或包,使其在不同环境中一致部署变得更容易。

  • 模型监控:实施监控和日志记录解决方案,以实时跟踪模型的性能和行为。这有助于早期发现问题并确保模型的持续可靠性。

  • 可扩展性和基础设施管理:设计和管理底层基础设施以支持生产中机器学习模型的需求,确保它们能够处理增加的工作负载并高效扩展。

  • 模型治理和合规性:实施流程和工具以确保在部署和使用机器学习模型时符合法律和伦理要求、隐私法规和公司政策。

  • 协作与沟通:促进数据科学家、工程师以及其他参与 ML 部署流程的利益相关者之间的有效沟通和协作。

通过采用 MLOps 原则,组织可以在保持模型在实际应用中的可靠性和有效性的同时,加速 ML 模型的开发和部署。这也有助于降低部署失败的风险,并在数据科学和运营团队中促进协作和持续改进的文化。

ML 管道 – 如何在实际系统中使用 ML

在本地平台上训练和验证 ML 模型是使用 ML 管道的过程的开始。毕竟,如果我们不得不在客户的每一台计算机上重新训练 ML 模型,那么这将非常有限。

因此,我们通常将 ML 模型部署到模型仓库中。有几个流行的仓库,但使用最大社区的是 HuggingFace 仓库。在那个仓库中,我们可以部署模型和数据集,甚至创建模型可以用于实验的空间,而无需下载它们。让我们将训练好的模型部署到第十一章中的那个仓库。为此,我们需要在 huggingface.com 上有一个账户,然后我们就可以开始了。

将模型部署到 HuggingFace

首先,我们需要使用主页上的新建按钮创建一个新的模型,如图图 12.2所示:

图 12.2 – 创建模型的新按钮

图 12.2 – 创建模型的新按钮

然后,我们填写有关我们模型的信息,为其创建空间。图 12.3展示了这个过程的一个截图。在表单中,我们填写模型的名称、是否为私有或公共,并为它选择一个许可证。在这个例子中,我们选择了 MIT 许可证,这是一个非常宽松的许可证,允许每个人只要包含 MIT 许可证文本,就可以使用、重新使用和重新分发模型:

图 12.3 – 模型元数据卡片

图 12.3 – 模型元数据卡片

一旦模型创建完成,我们就可以开始部署模型了。空余空间看起来就像图 12.4中的那样:

图 12.4 – 空余模型空间

图 12.4 – 空余模型空间

顶部菜单包含四个选项,但前两个是最重要的 – 模型卡片文件和版本。模型卡片是对模型的简要描述。它可以包含任何类型的信息,但最常见的信息是模型的使用方法。我们遵循这个惯例,并按照图 12.5所示准备模型卡片:

图 12.5 – 我们 wolfBERTa 模型卡片的开头

图 12.5 – 我们 wolfBERTa 模型卡片的开头

最佳实践 #60

模型卡片应包含有关模型如何训练、如何使用它、它支持哪些任务以及如何引用模型的信息。

由于 HuggingFace 是一个社区,因此正确记录创建的模型并提供有关模型如何训练以及它们能做什么的信息非常重要。因此,我的最佳实践是将所有这些信息包含在模型卡片中。许多模型还包括有关如何联系作者以及模型在训练之前是否已经预训练的信息。

一旦模型卡片准备就绪,我们就可以转到Readme.txt(即模型卡片),并可以添加实际的模型文件(见图 12.6):

图 12.6 – 模型的文件和版本;我们可以在右上角使用“添加文件”按钮添加模型

图 12.6 – 模型的文件和版本;我们可以在右上角使用“添加文件”按钮添加模型

一旦我们点击wolfBERTa子文件夹。该文件夹包含以下文件:

Mode                 LastWriteTime                     Name
------        --------------------        -----------------
d----l        2023-07-01     10:25        checkpoint-340000
d----l        2023-07-01     10:25        checkpoint-350000
-a---l        2023-06-27     21:30        config.json
-a---l        2023-06-27     17:55        merges.txt
-a---l        2023-06-27     21:30        pytorch_model.bin
-a---l        2023-06-27     21:30        training_args.bin
-a---l        2023-06-27     17:55        vocab.json

前两个条目是模型检查点;即我们在训练过程中保存的模型版本。这两个文件夹对于部署来说并不重要,因此将被忽略。其余的文件应复制到 HuggingFace 上新建的模型仓库中。

模型上传后,应该看起来像图 12.7中展示的那样:

图 12.7 – 上传到 HuggingFace 仓库的模型

图 12.7 – 上传到 HuggingFace 仓库的模型

在此之后,该模型就准备好供社区使用了。我们还可以为社区创建一个推理 API,以便他们快速测试我们的模型。一旦我们回到模型卡片菜单,在托管推理 API部分(图 12.8的右侧)就会自动提供给我们:

图 12.8 – 为我们的模型自动提供的托管推理 API

图 12.8 – 为我们的模型自动提供的托管推理 API

当我们输入int HTTP_get(<mask>)时,我们要求模型为该函数提供输入参数。结果显示,最可能的标记是void,其次是int标记。这两个都是相关的,因为它们是参数中使用的类型,但它们可能不会使这个程序编译,因此我们需要开发一个循环,预测程序中的不止一个标记。可能还需要更多的训练。

现在,我们有一个完全部署的模型,可以在其他应用中使用而无需太多麻烦。

从 HuggingFace 下载模型

我们已经看到了如何从 HuggingFace 下载模型,但为了完整性,让我们看看如何为wolfBERTa模型执行此操作。本质上,我们遵循模型卡片并使用以下 Python 代码片段:

from transformers import pipeline
unmasker = pipeline('fill-mask', model='mstaron/wolfBERTa')
unmasker("Hello I'm a <mask> model.")

此代码片段下载模型并使用 unmasker 接口通过 fill-mask 管道进行推理。该管道允许您输入一个带有 <mask> 掩码标记的句子,模型将尝试预测最适合填充掩码位置的单词。此代码片段中的三行代码执行以下操作:

  • from transformers import pipeline: 这行代码从 transformers 库中导入管道函数。管道函数简化了使用预训练模型进行各种自然语言处理NLP)任务的过程。

  • unmasker = pipeline('fill-mask', model='mstaron/wolfBERTa'): 这行代码为任务创建了一个名为 unmasker 的新管道。该管道将使用预训练的 wolfBERTa 模型。

  • unmasker("Hello I'm a <mask> model."): 这行代码利用 unmasker 管道来预测最适合给定句子中掩码位置的单词。<mask> 标记表示模型应尝试填充单词的位置。

当执行此行代码时,管道将调用 wolfBERTa 模型,并根据提供的句子进行预测。该模型将在 <mask> 标记的位置预测最佳单词以完成句子。

可以以非常相似的方式使用其他模型。像 HuggingFace 这样的社区模型中心的主要优势是它提供了一种统一管理模型和管道的绝佳方式,并允许我们在软件产品中快速交换模型。

基于原始数据的管道

创建完整的管道可能是一项艰巨的任务,需要为所有模型和所有类型的数据创建定制工具。它允许我们优化模型的使用方式,但需要付出大量努力。管道背后的主要理念是将机器学习的两个领域——模型及其计算能力与任务和领域数据联系起来。幸运的是,对于像 HuggingFace 这样的主要模型中心,它们提供了一个 API,可以自动提供机器学习管道。HuggingFace 中的管道与模型相关,并由基于模型架构、输入和输出的框架提供。

与自然语言处理相关的管道

文本分类是一个管道,旨在将文本输入分类到预定义的类别或类别中。它特别适用于情感分析SA)、主题分类、垃圾邮件检测、意图识别等任务。该管道通常采用针对不同分类任务在特定数据集上微调的预训练模型。我们在本书的第一部分使用机器学习进行代码审查的情感分析时,已经看到了类似的功能。

下面的代码片段提供了一个示例:

from transformers import pipeline
# Load the text classification pipeline
classifier = pipeline("text-classification")
# Classify a sample text
result = classifier("This movie is amazing and highly recommended!")
print(result)

代码片段显示,实际上我们需要实例化管道的代码有两行(粗体显示),正如我们之前所见。

文本生成是另一个允许使用预训练语言模型(如 GPT-3)根据提供的提示或种子文本生成文本的流程。它能够为各种应用生成类似人类的文本,例如聊天机器人、创意写作、问答QA)等。

以下代码片段展示了这样的一个示例:

from transformers import pipeline
# Load the text generation pipeline
generator = pipeline("text-generation")
# Generate text based on a prompt
prompt = "In a galaxy far, far away… "
result = generator(prompt, max_length=50, num_return_sequences=3)
for output in result:
    print(output['generated_text'])

摘要是设计用于将较长的文本总结为较短、连贯的摘要的流程。它利用了在大型数据集上针对摘要任务进行训练的基于 transformer 的模型。以下代码片段展示了该流程的示例:

from transformers import pipeline
# Load the summarization pipeline
summarizer = pipeline("summarization")
# Summarize a long article
article = """
In a groundbreaking discovery, scientists have found a new species of dinosaur in South America. The dinosaur, named "Titanus maximus," is estimated to have been the largest terrestrial creature to ever walk the Earth. It belonged to the sauropod group of dinosaurs, known for their long necks and tails. The discovery sheds new light on the diversity of dinosaurs that once inhabited our planet.
"""
result = summarizer(article, max_length=100, min_length=30, do_sample=False)
print(result[0]['summary_text'])

HuggingFace 的transformers API 中还有更多流程,所以我鼓励您查看这些流程。然而,我关于流程的最佳实践是这样的:

最佳实践 #61

尝试不同的模型以找到最佳流程。

由于 API 为类似模型提供了相同的流程,因此更改模型或其版本相当简单。因此,我们可以基于具有类似(但不同)功能(但不是相同)的模型创建产品,并同时训练模型。

图像流程

图像处理流程专门设计用于与图像处理相关的任务。HuggingFace hub 包含这些流程中的几个,以下是一些最受欢迎的。

图像分类专门设计用于将图像分类到特定类别。这与可能是最广为人知的任务相同——将图像分类为“猫”、“狗”或“车”。以下代码示例(来自 HuggingFace 教程)展示了图像分类流程的使用:

from transformers import pipeline
# first, create an instance of the image classification pipeline for the selected model
classifier = pipeline(model="microsoft/beit-base-patch16-224-pt22k-ft22k")
# now, use the pipeline to classify an image
classifier("https://huggingface.co/datasets/Narsil/image_dummy/raw/main/parrots.png")

前面的代码片段表明,创建图像分类流程与创建文本分析任务的流程一样容易(如果不是更容易)。

当我们想要向图像添加所谓的语义地图时,会使用图像分割流程(见图 12.9):

图 12.9 – 图像的语义地图,与我们第三章中看到的一样

图 12.9 – 图像的语义地图,与我们第三章中看到的一样第三章

下一个示例代码片段(同样来自 HuggingFace 教程)展示了包含此类流程的示例代码:

from transformers import pipeline
segmenter = pipeline(model="facebook/detr-resnet-50-panoptic")
segments = segmenter("https://huggingface.co/datasets/Narsil/image_dummy/raw/main/parrots.png")
segments[0]["label"]

前面的代码片段创建了一个图像分割流程,使用它并将结果存储在segments列表中。列表的最后一行打印出第一个分割的标签。使用segments[0]["mask"].size语句,我们可以接收到图像地图的像素大小。

目标检测流程用于需要识别图像中预定义类别对象的任务。我们已经在第三章中看到了这个任务的示例。此类流程的代码看起来与前几个非常相似:

from transformers import pipeline
detector = pipeline(model="facebook/detr-resnet-50")
detector("https://huggingface.co/datasets/Narsil/image_dummy/raw/main/parrots.png")

执行此代码将创建一个包含图像中检测到的对象的边界框列表,以及其边界框。我在使用管道处理图像方面的最佳实践与语言任务相同。

基于特征的管道

基于特征的管道没有特定的类,因为它们处于更低级别。它们是标准 Python 机器学习实现中的 model.fit()model.predict() 语句。这些管道要求软件开发者手动准备数据,并手动处理结果;也就是说,通过实现预处理步骤,如使用独热编码将数据转换为表格,以及后处理步骤,如将数据转换为人类可读的输出。

这种管道的一个例子是预测书中前几部分中看到的缺陷;因此,它们不需要重复。

然而,重要的是,所有管道都是将机器学习领域与软件工程领域联系起来的方式。我在开发管道后的第一个活动就是对其进行测试。

机器学习管道的测试

机器学习管道的测试在多个层面上进行,从单元测试开始,然后向上发展到集成(组件)测试,最后到系统测试和验收测试。在这些测试中,有两个元素很重要——模型本身和数据(对于模型和预言机)。

虽然我们可以使用 Python 内置的单元测试框架,但我强烈推荐使用 Pytest 框架,因为它简单灵活。我们可以通过以下命令安装此框架:

>> pip install pytest

这将下载并安装所需的包。

最佳实践 #62

使用像 Pytest 这样的专业测试框架。

使用专业框架为我们提供了 MLOps 原则所需的兼容性。我们可以共享我们的模型、数据、源代码以及所有其他元素,而无需繁琐地设置和安装框架本身。对于 Python,我推荐使用 Pytest 框架,因为它广为人知,被广泛使用,并且得到一个庞大社区的支持。

这里是一个下载模型并为其测试做准备代码片段:

# import json to be able to read the embedding vector for the test
import json
# import the model via the huggingface library
from transformers import AutoTokenizer, AutoModelForMaskedLM
# load the tokenizer and the model for the pretrained SingBERTa
tokenizer = AutoTokenizer.from_pretrained('mstaron/SingBERTa')
# load the model
model = AutoModelForMaskedLM.from_pretrained("mstaron/SingBERTa")
# import the feature extraction pipeline
from transformers import pipeline
# create the pipeline, which will extract the embedding vectors
# the models are already pre-defined, so we do not need to train anything here
features = pipeline(
    "feature-extraction",
    model=model,
    tokenizer=tokenizer,
    return_tensor = False
)

这段代码用于加载和设置预训练的语言模型,特别是 SingBERTa 模型,使用 Hugging Face 的 transformers 库。它包含以下元素:

  1. transformers 库导入必要的模块:

    1. AutoTokenizer:这个类用于自动选择适合预训练模型的适当分词器。

    2. AutoModelForMaskedLM:这个类用于自动选择适合 掩码语言模型MLM)任务的适当模型。

  2. 加载预训练的 SingBERTa 模型的分词器和模型:

    1. tokenizer = AutoTokenizer.from_pretrained('mstaron/SingBERTa'):这一行从 Hugging Face 模型库中加载预训练的 SingBERTa 模型的分词器。

    2. model = AutoModelForMaskedLM.from_pretrained("mstaron/SingBERTa"):这一行加载预训练的SingBERTa模型。

  3. 导入特征提取管道:

    1. from transformers import pipeline:这一行从transformers库中导入管道类,这使得我们能够轻松地为各种 NLP 任务创建管道。
  4. 创建特征提取管道:

    1. features = pipeline("feature-extraction", model=model, tokenizer=tokenizer, return_tensor=False):这一行创建一个用于特征提取的管道。该管道使用之前加载的预训练模型和分词器从输入文本中提取嵌入向量。return_tensor=False参数确保输出将以非张量格式(可能是 NumPy 数组或 Python 列表)返回。

使用这个设置,你现在可以使用features管道从文本输入中提取嵌入向量,而无需进行任何额外的训练,使用预训练的SingBERTa模型。我们之前已经看到过这个模型的使用,所以在这里,让我们专注于它的测试。以下代码片段是一个测试用例,用于检查模型是否已正确下载并且准备好使用:

def test_features():
    # get the embeddings of the word "Test"
    lstFeatures = features("Test")
    # read the oracle from the json file
    with open('test.json', 'r') as f:
        lstEmbeddings = json.load(f)
    # assert the embeddings and the oracle are the same
    assert lstFeatures[0][0] == lstEmbeddings

这个代码片段定义了一个test_features()测试函数。该函数的目的是通过将管道从之前代码片段中创建的特征提取管道获得的单词"Test"的嵌入与存储在名为'test.json'的 JSON 文件中的预期嵌入进行比较来测试特征提取管道的正确性。该文件的内容是我们的预言,它是一个包含大量数字的大向量,我们用它来与实际模型输出进行比较:

  • lstFeatures = features("Test"):这一行使用之前定义的features管道提取单词"Test"的嵌入。features管道是使用预训练的SingBERTa模型和分词器创建的。该管道将输入"Test"通过分词器处理,然后通过模型,并返回嵌入向量作为lstFeatures

  • with open('test.json', 'r') as f::这一行使用上下文管理器(with语句)以读取模式打开'test.json'文件。

  • lstEmbeddings = json.load(f):这一行读取'test.json'文件的内容,并将其内容加载到lstEmbeddings变量中。该 JSON 文件应包含表示预期嵌入的单词"Test"的嵌入向量的列表。

  • assert lstFeatures[0][0] == lstEmbeddings:这一行执行断言以检查从管道获得的嵌入向量(lstFeatures[0][0])是否等于从 JSON 文件中获得的预期嵌入向量(预言)。通过检查两个列表中相同位置的元素是否相同来进行比较。

如果断言为true(即管道提取的嵌入向量与 JSON 文件中预期的向量相同),则测试将通过而没有任何输出。然而,如果断言为false(即嵌入不匹配),则测试框架(Pytest)将此测试用例标记为失败。

为了执行测试,我们可以在与我们的项目相同的目录中编写以下语句:

>> pytest

在我们的情况下,这导致以下输出(为了简洁起见,已省略):

=================== test session starts ===================
platform win32 -- Python 3.11.4, pytest-7.4.0, pluggy-1.2.0
rootdir: C:\machine_learning_best_practices\chapter_12
plugins: anyio-3.7.0
collected 1 item
chapter_12_download_model_test.py .                 [100%]
====================== 1 passed in 4.17s ==================

这个片段显示,框架找到了一个测试用例(收集了 1 个条目)并执行了它。它还说明该测试用例在 4.17 秒内通过。

因此,接下来是我的下一个最佳实践。

最佳实践 #63

根据您的训练数据设置测试基础设施。

由于模型本质上是概率性的,因此最好基于训练数据来测试模型。这里,我的意思不是我们在机器学习(ML)的意义上测试性能,比如准确性。我的意思是测试模型是否真正工作。通过使用与训练相同的相同数据,我们可以检查模型对之前使用的数据的推理是否正确。因此,我指的是在软件工程意义上这个词的测试。

现在,类似于之前提出的语言模型,我们可以使用类似的方法来测试一个经典 ML 模型。有时这被称为零表测试。在这个测试中,我们使用只有一个数据点的简单数据来测试模型的预测是否正确。以下是设置此类测试的方法:

# import the libraries pandas and joblib
import pandas as pd
import joblib
# load the model
model = joblib.load('./chapter_12_decision_tree_model.joblib')
# load the data that we used for training
dfDataAnt13 = pd.read_excel('./chapter_12.xlsx',
                            sheet_name='ant_1_3',
                            index_col=0)

这段代码片段使用joblib库加载一个 ML 模型。在这种情况下,它是我们训练经典 ML 模型时在第十章中使用的模型。它是一个决策树模型。

然后,程序读取我们用于训练模型的相同数据集,以确保数据的格式与训练数据集完全相同。在这种情况下,我们可以期望得到与训练数据集相同的结果。对于更复杂的模型,我们可以在模型训练后直接进行一次推理,在保存模型之前创建这样的表。

现在,我们可以在以下代码片段中定义三个测试用例:

# test that the model is not null
# which means that it actually exists
def test_model_not_null():
    assert model is not None
# test that the model predicts class 1 correctly
# here correctly means that it predicts the same way as when it was trained
def test_model_predicts_class_correctly():
    X = dfDataAnt13.drop(['Defect'], axis=1)
    assert model.predict(X)[0] == 1
# test that the model predicts class 0 correctly
# here correctly means that it predicts the same way as when it was trained
def test_model_predicts_class_0_correctly():
    X = dfDataAnt13.drop(['Defect'], axis=1)
    assert model.predict(X)[1] == 0

第一个测试函数(test_model_not_null)检查model变量,该变量预期将包含训练好的 ML 模型,是否不是null。如果模型是null(即不存在),则assert语句将引发异常,表示测试失败。

第二个测试函数(test_model_predicts_class_correctly)检查模型是否正确预测了给定数据集的类别 1。它是通过以下方式完成的:

  • 通过从dfDataAnt13 DataFrame 中删除'Defect'列来准备X输入特征,假设'Defect'是目标列(类别标签)。

  • 使用训练好的模型(model.predict(X))对X输入特征进行预测。

  • 断言第一次预测(model.predict(X)[0])应该等于 1(类别 1)。如果模型正确预测类别 1,则测试通过;否则,将引发异常,表示测试失败。

第三个测试用例(test_model_predicts_class_0_correctly)检查模型是否正确预测给定数据集的类别 0。它遵循与上一个测试类似的过程:

  • 通过从dfDataAnt13 DataFrame 中删除'Defect'列来准备X输入特征。

  • 使用训练好的模型(model.predict(X))对X输入特征进行预测。

  • 断言第二次预测(model.predict(X)[1])应该等于 0(类别 0)。如果模型正确预测类别 0,则测试通过;否则,将引发异常,表示测试失败。

这些测试验证了训练模型的完整性和正确性,并确保它在给定的数据集上按预期执行。以下是执行测试的输出:

=============== test session starts =======================
platform win32 -- Python 3.11.4, pytest-7.4.0, pluggy-1.2.0
rootdir: C:\machine_learning_best_practices\chapter_12
plugins: anyio-3.7.0
collected 4 items
chapter_12_classical_ml_test.py                      [ 75%]
chapter_12_download_model_test.py                    [100%]
================ 4 passed in 12.76s =======================

Pytest 框架找到了我们所有的测试,并显示其中三个(四个中的三个)位于chapter_12_classical_ml_test.py文件中,另一个位于chapter_12_downloaded_model_test.py文件中。

因此,我的下一个最佳实践是:

最佳实践#64

将模型视为单元并相应地为它们准备单元测试。

我建议将 ML 模型视为单元(与模块相同)并为其使用单元测试实践。这有助于减少模型概率性质的影响,并为我们提供了检查模型是否正确工作的可能性。它有助于在之后调试整个软件系统。

监控 ML 系统运行时

监控生产中的管道是 MLOps 的关键方面,以确保部署的 ML 模型的表现、可靠性和准确性。这包括几个实践。

第一个实践是记录和收集指标。这项活动包括在 ML 代码中添加日志语句,以在模型训练和推理期间捕获相关信息。要监控的关键指标包括模型准确性、数据漂移、延迟和吞吐量。流行的日志和监控框架包括 Prometheus、Grafana 以及Elasticsearch、Logstash 和 KibanaELK)。

第二个是警报,它基于预定义的关键指标阈值设置警报。这有助于在生产管道中主动识别问题或异常。当警报被触发时,适当的团队成员可以被通知进行调查并迅速解决问题。

数据漂移检测是第三项活动,包括监控输入数据的分布以识别数据漂移。数据漂移指的是数据分布随时间的变化,这可能会影响模型性能。

第四个活动是性能监控,MLOps 团队持续跟踪已部署模型的性能。他们测量推理时间、预测准确度以及其他相关指标,并监控性能下降,这可能由于数据、基础设施或依赖关系的变化而引起。

除了这四个主要活动之外,MLOps 团队还有以下职责:

  • 错误分析:使用工具分析和记录推理过程中遇到的错误,并理解错误的本质,可以帮助改进模型或识别数据或系统中的问题。

  • 模型版本控制:跟踪模型版本及其随时间的变化性能,并在最新部署出现问题时(如果需要)回滚到之前的版本。

  • 环境监控:使用诸如 CPU/内存利用率、网络流量等关键绩效指标监控模型部署的基础设施和环境,寻找性能瓶颈。

  • 安全和合规性:确保部署的模型遵守安全和合规性标准,并监控访问日志和任何可疑活动。

  • 用户反馈:收集、分析和将用户反馈融入监控和推理过程中。MLOps 从最终用户那里征求反馈,以从现实世界的角度了解模型的性能。

通过有效地监控管道,MLOps 可以迅速响应任何出现的问题,提供更好的用户体验,并维护 ML 系统的整体健康。然而,监控所有上述方面相当费力,并非所有 MLOps 团队都有资源去做。因此,我在本章的最后一条最佳实践是:

最佳实践#65

识别 ML 部署的关键方面,并相应地监控这些方面。

虽然这听起来很简单,但并不总是容易识别关键方面。我通常从优先监控基础设施和日志记录以及收集指标开始。监控基础设施很重要,因为任何问题都会迅速传播到客户那里,导致失去信誉,甚至业务损失。监控指标和日志记录为 ML 系统的运行提供了深刻的洞察,并防止了许多 ML 系统生产中的问题。

摘要

构建 ML 管道是本书专注于 ML 核心技术方面的部分。管道对于确保 ML 模型按照软件工程的最佳实践使用至关重要。

然而,ML 管道仍然不是一个完整的 ML 系统。它们只能提供数据的推理并提供输出。为了使管道有效运行,它们需要连接到系统的其他部分,如用户界面和存储。这就是下一章的内容。

参考文献

  • A. LimaL. Monteiro,和 A.P. FurtadoMLOps:实践、成熟度模型、角色、工具和挑战的系统文献综述ICEIS (1),2022: p. 308-320

  • John, M.M.Olsson, H.H.,和 Bosch, J.迈向 MLOps:一个框架和成熟度模型。在2021 年第 47 届欧姆尼微软件工程和高级应用会议(SEAA)2021IEEE

  • Staron, M. et al., 从演化的测量系统到自愈系统以提高可用性的工业经验软件:实践与经验201848(3)p. 719-739