原文:
annas-archive.org/md5/ec14cdde5f82b4b7e0113bdbb2bbe4c7
译者:飞龙
第五章:使用最近邻进行图像处理
在本章及随后的章节中,我们将采取不同的方法。最近邻算法将在这里担任辅助角色,而图像处理将是本章的主要内容。我们将从加载图像开始,并使用 Python 将它们表示为适合机器学习算法处理的格式。我们将使用最近邻算法进行分类和回归。我们还将学习如何将图像中的信息压缩到更小的空间中。这里解释的许多概念是可转移的,并且可以通过稍微调整后用于其他算法。稍后,在第七章,神经网络——深度学习的到来中,我们将基于在这里获得的知识,继续使用神经网络进行图像处理。在本章中,我们将涵盖以下主题:
-
最近邻
-
加载和显示图像
-
图像分类
-
使用自定义距离
-
使用最近邻进行回归
-
降维我们的图像数据
最近邻
“我们通过示例和直接经验来学习,因为口头指导的充分性是有限的。”
– 马尔科姆·格拉德威尔
好像马尔科姆·格拉德威尔在前述引用中解释 K 近邻算法;我们只需要将“口头指导”替换为“数学方程”。像线性模型这样的情况中,训练数据用于学习一个数学方程来模拟数据。一旦模型被学习,我们可以轻松地将训练数据搁置一旁。在最近邻算法中,数据本身就是模型。每当遇到一个新的数据样本时,我们将其与训练数据集进行比较。我们定位到训练集中与新样本最近的 K 个样本,然后使用这些 K 个样本的类别标签为新样本分配标签。
这里有几点需要注意:
-
训练的概念在这里并不存在。与其他算法不同,在其他算法中,训练时间取决于训练数据的数量,而在最近邻算法中,计算成本大部分花费在预测时的最近邻计算上。
-
最近关于最近邻算法的大部分研究都集中在寻找在预测时快速搜索训练数据的最佳方法。
-
最近意味着什么?在本章中,我们将学习用于比较不同数据点之间距离的不同度量方法。两个数据点是否接近彼此,取决于使用的距离度量标准。
-
K是什么?我们可以将一个新数据点与训练集中的 1、2、3 或 50 个样本进行比较。我们决定比较的样本数量就是K,我们将看到不同的K值如何影响算法的行为。
在使用最近邻算法进行图像分类之前,我们需要先学习如何处理图像。在接下来的章节中,我们将加载并展示机器学习和图像处理领域中最常用的图像数据集之一。
在查找一个样本的最近邻时,可以将其与所有其他训练样本进行比较。这是一种简单的暴力方法,当训练数据集规模增大时,效果并不好。对于更大的数据集,一种更高效的方法是将训练样本存储在一个特定的数据结构中,该数据结构经过优化以便于搜索。K-D 树和球树是两种可用的数据结构。这两种数据结构通过leaf_size
参数进行调整。当其值接近训练集的大小时,K-D 树和球树就变成了暴力搜索。相反,将叶子大小设置为1
会在遍历树时引入大量开销。默认的叶子大小为30
,对于许多样本大小来说,这是一个不错的折中值。
加载并显示图像
“照片是二维的。我在四维空间中工作。”
– Tino Sehgal
当被问到图像的维度时,摄影师、画家、插画家以及几乎地球上所有人都会认为图像是二维的物体。只有机器学习从业者会从不同的角度看待图像。对我们来说,黑白图像中的每个像素都是一个单独的维度。随着彩色图像的出现,维度会进一步增加,但那是后话。我们将每个像素视为一个单独的维度,以便我们能够将每个像素及其值当作定义图像的独特特征,与其他像素(特征)一起处理。所以,和Tino Sehgal不同,我们有时会处理 4000 维。
修改后的国家标准与技术研究院(MNIST)数据集是一个手写数字的集合,通常用于图像处理。由于其受欢迎程度,它被包含在scikit-learn
中,我们可以像通常加载其他数据集一样加载它:
from sklearn.datasets import load_digits
digits = load_digits()
这个数据集包含从0
到9
的数字。我们可以通过以下方式访问它们的目标(标签):
digits['target']
# Output: array([0, 1, 2, ..., 8, 9, 8])
类似地,我们可以加载像素值,如下所示:
digits['data']
# Output:
# array([[ 0., 0., 5., ..., 0., 0., 0.],
# [ 0., 0., 0., ..., 10., 0., 0.],
# ...,
# [ 0., 0., 2., ..., 12., 0., 0.],
# [ 0., 0., 10., ..., 12., 1., 0.]])
每一行是一个图像,每一个整数是一个像素值。在这个数据集中,像素值的范围在0
到16
之间。数据集的形状(digits['data'].shape
)是1,797 x 64。换句话说,我们有 1,797 张方形的图片,每张图片有 64 个像素(宽度 = 高度 = 8)。
知道了这些信息后,我们可以创建以下函数来显示图像。它接受一个 64 个值的数组,并将其重塑成一个 8 行 8 列的二维数组。它还使用图像的对应目标值,在数字上方显示。matplotlib
的坐标轴(ax
)被传入,这样我们就可以在其上显示图像:
def display_img(img, target, ax):
img = img.reshape((8, 8))
ax.imshow(img, cmap='gray')
ax.set_title(f'Digit: {str(target)}')
ax.grid(False)
我们现在可以使用刚才创建的函数来显示数据集中的前八个数字:
fig, axs = plt.subplots(1, 8, figsize=(15, 10))
for i in range(8):
display_img(digits['data'][i], digits['target'][i], axs[i])
fig.show()
数字显示如下:
能够显示数字是一个很好的第一步。接下来,我们需要将它们转换为我们通常的训练和测试格式。这次,我们希望将每张图片保留为一行,因此不需要将其重塑为8 x 8矩阵:
from sklearn.model_selection import train_test_split
x, y = digits['data'], digits['target']
x_train, x_test, y_train, y_test = train_test_split(x, y)
到此为止,数据已经准备好用于图像分类算法。通过学习在给定一堆像素时预测目标,我们已经离让计算机理解手写文本更近了一步。
图像分类
现在我们已经准备好了数据,可以使用最近邻分类器来预测数字,如下所示:
from sklearn.neighbors import KNeighborsClassifier
clf = KNeighborsClassifier(n_neighbors=11, metric='manhattan')
clf.fit(x_train, y_train)
y_test_pred = clf.predict(x_test)
对于这个例子,我将n_neighbors
设置为11
,metric
设置为manhattan
,意味着在预测时,我们将每个新样本与 11 个最接近的训练样本进行比较,使用曼哈顿距离来评估它们的接近程度。稍后会详细讲解这些参数。该模型在测试集上的预测准确率为 96.4%。这听起来可能很合理,但很抱歉告诉你,这对于这个特定的数据集来说并不是一个很棒的得分。无论如何,我们继续深入分析模型的表现。
使用混淆矩阵理解模型的错误
当处理具有 10 个类别标签的数据集时,单一的准确率得分只能告诉我们一些信息。为了更好地理解哪些数字比其他数字更难猜测,我们可以打印出模型的混淆矩阵。这是一个方阵,其中实际标签作为行显示,预测标签作为列显示。然后,每个单元格中的数字表示落入该单元格的测试实例。让我现在创建它,很快你就能看得更清楚。plot_confusion_matrix
函数需要分类器实例,以及测试的x
和y
值,才能显示矩阵:
from sklearn.metrics import plot_confusion_matrix
plot_confusion_matrix(clf, x_test, y_test, cmap='Greys')
一旦调用,该函数会在内部对测试数据运行模型,并显示以下矩阵:
理想情况下,所有单元格应为零,除了对角线上的单元格。落入对角线单元格意味着样本被正确标记。然而,这里只有少数几个非零单元格。位于第 8 行和第 1 列交点的四个样本表明,我们的模型将四个样本分类为1
,而它们的实际标签是8
。很可能,它们是看起来像 1 的过于瘦弱的 8。对于其余的非对角线非零单元格,也可以得出相同的结论。
选择合适的度量标准
我们使用的图像只是数字列表(向量)。距离度量决定了一个图像是否接近另一个图像。这同样适用于非图像数据,其中距离度量用于决定一个样本是否接近另一个样本。两种常用的度量标准是曼哈顿距离和欧几里得距离:
很可能,曼哈顿距离的公式会让你想起平均绝对误差和 L1 正则化,而欧几里得距离则类似于均方误差和 L2 正则化。这种相似性很好地提醒我们,许多概念都来源于共同的思想:
对于曼哈顿距离,A 和 C 之间的距离是通过从 A 到 D,再从 D 到 C 来计算的。它得名于纽约的曼哈顿岛,因为那里有着分块的景观。对于欧几里得距离,A 和 C 之间的距离是通过两点之间的对角线来计算的。这两种度量有一个广义的形式,叫做闵可夫斯基距离,其公式如下:
设置p
为1
时,我们得到曼哈顿距离,设置为2
时可以得到欧几里得距离。我相信你现在可以看出,L1 和 L2 范数中的1
和2
来自哪里。为了能够比较不同p
值的结果,我们可以运行以下代码。在这里,我们计算了两点之间的闵可夫斯基距离——(1, 2)
和(4, 6)
——对于不同p
值的情况:
from sklearn.neighbors import DistanceMetric
points = pd.DataFrame(
[[1, 2], [4, 6]], columns=['x1', 'x2']
)
d = [
(p, DistanceMetric.get_metric('minkowski', p=p).pairwise(points)[0][-1])
for p in [1, 2, 10, 50, 100]
]
绘制结果可以显示出闵可夫斯基距离如何随p
变化:
显然,闵可夫斯基距离随着p
的增加而减小。对于p = 1
,距离为7
,即(4 - 1) + (6 - 2)
,而对于p = 2
,距离为5
,即(9 + 16)
的平方根。对于更大的p
值,计算出的距离接近4
,也就是(6 - 2)
。换句话说,随着p
趋近于无穷大,距离就是所有坐标轴上点间跨度的最大值,这就是所谓的切比雪夫距离。
度量一词用来描述符合以下标准的距离度量:
它不能是负值:https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/e1e821f0-1c45-4c76-9137-9850474e9a52.png,并且它是对称的:https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/b98b8d27-e50e-4273-a005-ca7dbb7b1e35.png。
从一个点到它自身的距离是 0。它遵循以下三角不等式准则:https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/73493af6-edf4-40b2-ad39-bf4f833fc034.png。
另一种常见的度量是余弦距离,其公式如下:
与欧几里得距离不同,余弦距离对尺度不敏感。我认为通过以下示例展示两者的区别会更好。
这里,我们取一个数字并将每个像素值乘以2
:
现在,我们来计算原始图像和强化图像之间的距离:
from sklearn.metrics.pairwise import (
euclidean_distances,
manhattan_distances,
cosine_distances
)
d0 = manhattan_distances(
[1.0 * digits['data'][0], 2.0 * digits['data'][0]]
)[0,1]
d1 = euclidean_distances(
[1.0 * digits['data'][0], 2.0 * digits['data'][0]]
)[0,1]
d2 = cosine_distances(
[1.0 * digits['data'][0], 2.0 * digits['data'][0]]
)[0,1]
运行上述代码给我们每个距离的值——曼哈顿距离 = 294
,欧氏距离 = 55.41
,余弦距离 = 0
。如预期,余弦距离不关心我们用来乘以像素的常数,并且它将两个相同图像的版本视为一样。另外两个度量标准则认为这两个版本之间有更大的距离。
设置正确的 K
在选择度量标准同样重要的是知道在做决定时要听取多少个邻居的意见。你不希望询问太少的邻居,因为他们可能了解不足。你也不希望问每个人,因为远距离的邻居可能对手头的样本了解不多。正式地说,基于过少邻居做出的决定会引入方差,因为数据的轻微变化会导致不同的邻域和不同的结果。相反,基于过多邻居做出的决定是有偏的,因为它对邻域之间的差异不太敏感。请记住这一点。在这里,我使用了不同K设置的模型,并绘制了结果准确度:
偏差-方差权衡的概念将贯穿本书始终。在选择方向时,通常在训练集较小时选择使用有偏模型。如果没有足够的数据进行学习,高方差模型会过拟合。最偏差的模型是当K设置为训练样本数时。然后,所有新数据点将得到相同的预测,并被分配给与多数类相同的标签。相反,当我们有足够的数据时,较小半径内的少数最近邻是更好的选择,因为它们更有可能属于与我们新样本相同的类。
现在,我们有两个超参数需要设置:邻居数量和距离度量。在接下来的部分,我们将使用网格搜索来找到这些参数的最佳值。
使用 GridSearchCV 进行超参数调整
GridSearchCV
是一种遍历所有可能的超参数组合并使用交叉验证来选择最佳超参数的方法。对于每个超参数组合,我们并不想仅限于一个准确度得分。为了更好地理解每个组合的估算器准确性,我们使用 K 折交叉验证。然后,数据会被分割成若干折,在每次迭代中,除了一个折用于训练外,剩下的折用于测试。这个超参数调优方法对所有可能的参数组合进行穷举搜索,因此使用了Grid
前缀。在下面的代码中,我们给GridSearchCV
传入一个包含所有需要遍历的参数值的 Python 字典,以及我们想要调优的估算器。我们还指定了将数据划分成的折数,然后调用网格搜索的fit
方法并传入训练数据。请记住,从测试数据集中学习任何内容是一个不好的做法,测试集应该暂时被保留。以下是实现这一过程的代码:
from sklearn.model_selection import GridSearchCV
from sklearn.neighbors import KNeighborsClassifier
parameters = {
'metric':('manhattan','euclidean', 'cosine'),
'n_neighbors': range(1, 21)
}
knn = KNeighborsClassifier()
gscv = GridSearchCV(knn, param_grid=parameters, scoring='accuracy')
gscv.fit(x_train, y_train)
完成后,我们可以通过gscv.best_params_
显示通过GridSearchCV
找到的最佳参数。我们还可以通过gscv.best_score_
显示使用所选参数时得到的准确度。在这里,选择了euclidean
距离作为metric
,并将n_neighbors
设置为3
。在使用所选超参数时,我还得到了 98.7%的准确度得分。
我们现在可以使用得到的分类器对测试集进行预测:
from sklearn.metrics import accuracy_score
y_test_pred = gscv.predict(x_test)
accuracy_score(y_test, y_test_pred)
这让我在测试集上的准确度达到了 98.0%。幸运的是,网格搜索帮助我们通过选择最佳超参数来提高了估算器的准确度。
GridSearchCV
在我们需要搜索过多的超参数并且每个超参数有太多值时,会变得计算上非常昂贵。面对这种问题时,RandomizedSearchCV
可能是一个替代的解决方案,因为它在搜索过程中会随机选择超参数值。两种超参数调优算法默认都使用分类器的accuracy
得分和回归器的R
^(2
)得分。我们可以覆盖默认设置,指定不同的度量标准来选择最佳配置。
使用自定义距离
这里的数字是以白色像素写在黑色背景上的。如果数字是用黑色像素写在白色背景上,我想没有人会有问题识别这个数字。对于计算机算法来说,情况则有些不同。让我们像往常一样训练分类器,看看当颜色反转时,它是否会遇到任何问题。我们将从训练原始图像开始:
clf = KNeighborsClassifier(n_neighbors=3, metric='euclidean')
clf.fit(x_train, y_train)
y_train_pred = clf.predict(x_train)
然后,我们创建了刚刚用于训练的反转数据版本:
x_train_inv = x_train.max() - x_train
最近邻实现有一个叫做kneighbors
的方法。给定一个样本,它会返回训练集中与该样本最接近的 K 个样本及其与给定样本的距离。我们将给这个方法传递一个反转的样本,并观察它会将哪些样本视为邻居:
img_inv = x_train_inv[0]
fig, axs = plt.subplots(1, 8, figsize=(14, 5))
display_img(img_inv, y_train[0], axs[0])
_, kneighbors_index_inv = clf.kneighbors(
[x_train_inv[0]],
n_neighbors=7,
return_distance=True
)
for i, neighbor_index in enumerate(kneighbors_index_inv[0], 1):
display_img(
x_train[neighbor_index],
y_train[neighbor_index],
axs[i]
)
为了让事情更清晰,我运行了代码两次——一次使用原始样本及其七个邻居,另一次使用反转样本及其邻居。两次运行的输出结果如下所示。正如你所看到的,与我们人类不同,算法在处理颜色反转的对抗样本时完全混淆了:
如果你想一想,根据我们使用的距离度量,一个样本及其反转版本之间不应该相差太远。虽然我们从视觉上将它们视为同一个样本,但模型却将它们视为天壤之别。话虽如此,很显然我们需要找到一种不同的方式来评估距离。由于像素的值在0
和16
之间变化,在反转样本中,所有的 16 都变成了 0,15 变成了 1,以此类推。因此,一种比较样本之间像素与0
和16
之间中点(即8
)距离的度量可以帮助我们解决这里的问题。下面是如何创建这种自定义距离的方法。我们将这种新距离称为contrast_distance
:
from sklearn.metrics.pairwise import euclidean_distances
def contrast_distance(x1, x2):
_x1, _x2 = np.abs(8 - x1), np.abs(8 - x2)
d = euclidean_distances([_x1], [_x2])
return d[0][0]
一旦定义完毕,我们可以在分类器中使用自定义度量,如下所示:
clf = KNeighborsClassifier(n_neighbors=3, metric=contrast_distance)
clf.fit(x_train, y_train)
经过这个调整后,反转对模型不再造成困扰。对于原始样本和反转样本,我们得到了相同的 89.3%准确率。我们还可以根据新的度量标准打印出七个最近邻,验证新模型已经更聪明,并且不再歧视黑色数字:
编写自定义距离时需要记住的一件事是,它们不像内置的度量那样优化,因此在预测时运行算法将会更耗费计算资源。
使用最近邻回归
到头来,我们在 MNIST 数据集中预测的目标只是 0 到 9 之间的数字。所以,我们可以改用回归算法来解决同样的问题。在这种情况下,我们的预测不再是整数,而是浮动值。训练回归器与训练分类器没有太大区别:
from sklearn.neighbors import KNeighborsRegressor
clf = KNeighborsRegressor(n_neighbors=3, metric='euclidean')
clf.fit(x_train, y_train)
y_test_pred = clf.predict(x_test)
这里是一些错误的预测结果:
第一项的三个最近邻分别是3
、3
和5
。因此,回归器使用它们的平均值(3.67
)作为预测结果。第二项和第三项的邻居分别是8, 9, 8
和7, 9, 7
。记得如果你想用分类器的评估指标来评估这个模型,应该将这些预测四舍五入并转换成整数。
更多的邻域算法
我想在进入下一部分之前,快速介绍一些 K 近邻算法的其他变种。这些算法虽然不太常用,但它们也有自己的优点和某些缺点。
半径邻居
与 K 近邻算法不同,后者允许一定数量的邻居进行投票,而在半径邻居算法中,所有在一定半径内的邻居都会参与投票过程。通过设置预定义的半径,稀疏区域的决策将基于比密集区域更少的邻居进行。这在处理不平衡类别时可能非常有用。此外,通过使用哈弗辛公式作为我们的度量标准,我们可以使用此算法向用户推荐附近的场所或加油站。通过指定算法的weights
参数,半径邻居和 K 近邻都可以给予距离较近的数据点比远离的数据点更多的投票权。
最近质心分类器
正如我们所看到的,K 近邻算法将测试样本与训练集中的所有样本进行比较。这种全面搜索导致模型在预测时变得更慢。为了解决这个问题,最近中心分类器将每个类别的所有训练样本总结为一个伪样本,这个伪样本代表了该类别。这个伪样本被称为质心,因为它通常通过计算该类别中每个特征的平均值来创建。在预测时,测试样本会与所有质心进行比较,并根据与其最接近的质心所属的类别进行分类。
在下一部分,我们将使用质心算法进行训练和预测,但现在,我们将用它来生成新的数字,仅仅是为了好玩。算法的训练过程如下:
from sklearn.neighbors import NearestCentroid
clf = NearestCentroid(metric='euclidean')
clf.fit(x_train, y_train)
学到的质心存储在centroids_
中。以下代码显示这些质心以及类别标签:
fig, axs = plt.subplots(1, len(clf.classes_), figsize=(15, 5))
for i, (centroid, label) in enumerate(zip(clf.centroids_, clf.classes_)):
display_img(centroid, label, axs[i])
fig.show()
生成的数字如下所示:
这些数字在我们的数据集中并不存在。它们只是每个类别中所有样本的组合。
最近质心分类器相当简单,我相信你可以通过几行代码从头实现它。不过,它的准确度在 MNIST 数据集上不如最近邻算法。质心算法在自然语言处理领域中更为常见,在那里它更为人知的是 Rocchio(发音类似于“we will rock you”)。
最后,质心算法还有一个超参数,叫做shrink_threshold
。当设置时,这可以帮助去除无关特征。
降低我们图像数据的维度
之前,我们意识到图像的维度等于图像中的像素数量。因此,我们无法将我们的 43 维 MNIST 数据集可视化。确实,我们可以单独展示每个数字,但无法看到每个图像在特征空间中的位置。这对于理解分类器的决策边界非常重要。此外,估计器的内存需求随着训练数据中特征数量的增加而增长。因此,我们需要一种方法来减少数据中特征的数量,以解决上述问题。
在这一节中,我们将介绍两种降维算法:主成分分析(PCA)和邻域成分分析(NCA)。在解释这些方法后,我们将使用它们来可视化 MNIST 数据集,并生成额外的样本以加入我们的训练集。最后,我们还将使用特征选择算法,从图像中去除无信息的像素。
主成分分析
“一张好照片是知道站在哪里。”
– 安塞尔·亚当斯
假设我们有以下两个特征的数据集——x1
和x2
:
你可以使用以下代码片段生成一个之前的数据框,记住,由于其随机性,数字在你的机器上可能会有所不同:
df = pd.DataFrame(
{
'x1': np.random.normal(loc=10.0, scale=5.0, size=8),
'noise': np.random.normal(loc=0.0, scale=1.0, size=8),
}
)
df['x2'] = 3 * df['x1'] + df['noise']
当我们绘制数据时,我们会发现x1
和x2
呈现出如下的形式:
如果你愿意,可以把头偏向左边。现在,想象一下我们没有x1
和x2
轴,而是有一个通过数据的对角线轴。那条轴是否足以表示我们的数据呢?这样,我们就将其从一个二维数据集降维到一个一维数据集。这正是 PCA 试图实现的目标。
这个新轴有一个主要特点——轴上点与点之间的距离大于它们在x1
或x2
轴上的距离。记住,三角形的斜边总是大于其他两边中的任何一边。总之,PCA 试图找到一组新的轴(主成分),使得数据的方差最大化。
就像我们在第四章中讨论的相关系数方程一样,准备数据,PCA 也需要数据进行中心化。对于每一列,我们将该列的均值从每个值中减去。我们可以使用with_std=False
的标准化缩放器来实现这一点。以下是如何计算 PCA 并将我们的数据转换为新维度的过程:
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
scaler = StandardScaler(with_std=False)
x = scaler.fit_transform(df[['x1', 'x2']])
pca = PCA(n_components=1)
x_new = pca.fit_transform(x)
结果的x_new
值是一个单列数据框,而不是两个。我们也可以通过pca.components_
访问新创建的组件。在这里,我将新组件与原始数据一起绘制出来:
如你所见,我们能够使用 PCA 算法将特征的数量从两个减少到一个。由于点并没有完全落在直线上,仅使用一个成分会丢失一些信息。这些信息存储在我们没有提取的第二个成分中。你可以将数据转换为从一个到原始特征数目的任何数量的成分。成分根据它们所包含的信息量降序排列。因此,忽略后续成分可能有助于去除任何噪声和不太有用的信息。数据经过转换后,也可以进行反向转换(逆变换)。只有在保留所有成分的情况下,经过这两步操作得到的数据才与原始数据匹配;否则,我们可以仅限于前几个(主要)成分来去噪数据。
在 PCA 假设中,特征空间中方差最大的方向预计携带比方差较小的方向更多的信息。这个假设在某些情况下可能成立,但并不总是成立。请记住,在 PCA 中,目标变量不被使用,只有特征变量。这使得它更适合无标签数据。
邻域成分分析
在最近邻算法中,距离度量的选择至关重要,但通常是通过经验设定的。我们在本章前面使用了 K 折交叉验证来决定哪种距离度量更适合我们的任务。这个过程可能比较耗时,这也促使许多研究人员寻找更好的解决方案。NCA 的主要目标是通过梯度下降从数据中学习距离度量。它尝试学习的距离通常用一个方阵表示。对于N个样本,我们有 https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/73d8334b-b8ad-4011-9da8-370f39c173c7.png 个样本对需要比较,因此是方阵。然而,这个矩阵可以被限制为一个矩形矩阵,https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/64dcd70b-19c0-4a61-bf66-5ed6bc1db006.png,其中小n是比N小的数字,表示降维后的成分。这些降维后的成分是 NCA 的基础构建块。
最近邻算法属于一种称为基于实例的学习器的学习类别。我们使用训练集的实例来做出决策。因此,承载实例之间距离的矩阵是其中的重要部分。这个矩阵激发了许多研究人员对此进行研究。例如,从数据中学习距离是 NCA 和大边际最近邻的研究内容;其他研究人员将这个矩阵转换到更高维空间——例如,使用核技巧——还有一些研究人员尝试通过正则化将特征选择嵌入到基于实例的学习器中。
在下一部分,我们将通过使用 PCA 和 NCA 算法将 MNIST 数据集绘制到二维图形中,来直观地比较这两种降维方法。
将 PCA 与 NCA 进行比较
我们将通过将数据投影到更小的空间中来减少数据的维度。除了随机投影,我们还将使用 PCA 和 NCA。我们将首先导入所需的模型,并将这三种算法放入一个 Python 字典中,以便后续循环使用:
from sklearn.preprocessing import StandardScaler
from sklearn.random_projection import SparseRandomProjection
from sklearn.decomposition import PCA
from sklearn.neighbors import NeighborhoodComponentsAnalysis
methods = {
'Rand': SparseRandomProjection(n_components=2),
'PCA': PCA(n_components=2),
'NCA': NeighborhoodComponentsAnalysis(n_components=2, init='random'),
}
然后,我们将并排绘制三种算法的三个图表,如下所示:
fig, axs = plt.subplots(1, 3, figsize=(15, 5))
for i, (method_name, method_obj) in enumerate(methods.items()):
scaler = StandardScaler(with_std=False)
x_train_scaled = scaler.fit_transform(x_train)
method_obj.fit(x_train_scaled, y_train)
x_train_2d = method_obj.transform(x_train_scaled)
for target in set(y_train):
pd.DataFrame(
x_train_2d[
y_train == target
], columns=['y', 'x']
).sample(n=20).plot(
kind='scatter', x='x', y='y',
marker=f'${target}$', s=64, ax=axs[i]
)
axs[i].set_title(f'{method_name} MNIST')
在应用 PCA 之前,数据必须进行中心化。这时我们使用了 StandardScaler
来实现。其他算法本身应该不在乎是否进行中心化。运行代码后,我们得到以下图表:
PCA 和 NCA 在将相同的数字聚集在一起方面比随机投影表现得更好。除了视觉分析,我们还可以在降维后的数据上运行最近邻算法,判断哪种变换更能代表数据。我们可以使用与之前类似的代码,并将 for
循环中的内容替换为以下两段代码:
- 首先,我们需要对数据进行缩放和转换:
from sklearn.preprocessing import StandardScaler
from sklearn.preprocessing import MinMaxScaler
scaler = StandardScaler(with_std=False)
x_train_scaled = scaler.fit_transform(x_train)
x_test_scaled = scaler.fit_transform(x_test)
method_obj.fit(x_train_scaled, y_train)
x_train_2d = method_obj.transform(x_train_scaled)
x_test_2d = method_obj.transform(x_test_scaled)
scaler = MinMaxScaler()
x_train_scaled = scaler.fit_transform(x_train_2d)
x_test_scaled = scaler.transform(x_test_2d)
- 然后,我们使用交叉验证来设置最佳超参数:
from sklearn.neighbors import KNeighborsClassifier
from sklearn.model_selection import GridSearchCV
from sklearn.metrics import accuracy_score
parameters = {'metric':('manhattan','euclidean'), 'n_neighbors': range(3, 9)}
knn = KNeighborsClassifier()
clf = GridSearchCV(knn, param_grid=parameters, scoring='accuracy', cv=5)
clf.fit(x_train_scaled, y_train)
y_test_pred = clf.predict(x_test_scaled)
print(
'MNIST test accuracy score: {:.1%} [k={}, metric={} - {}]'.format(
accuracy_score(y_test, y_test_pred),
clf.best_params_['n_neighbors'],
clf.best_params_['metric'],
method_name
)
)
由于这次我们不需要可视化数据,可以将主成分数设置为 6
。这样我们得到以下的准确率。请记住,由于数据的随机拆分和估计器的初始值不同,你的结果可能会有所不同:
投影 | 准确率 |
---|---|
稀疏随机投影 | 73% |
PCA | 93% |
NCA | 95% |
在 PCA 中,不需要类标签。我只是为了保持一致性,在之前的代码中传递了它们,但算法实际上是忽略了这些标签。相比之下,在 NCA 中,算法是会使用类标签的。
选择最具信息量的主成分
在拟合 PCA 后,explained_variance_ratio_
包含了每个选择的主成分所解释的方差比例。根据主成分假设,较高的比例应反映更多的信息。我们可以将这些信息放入数据框中,如下所示:
df_explained_variance_ratio = pd.DataFrame(
[
(component, explained_variance_ratio)
for component, explained_variance_ratio in enumerate(pca.explained_variance_ratio_[:32], 1)
], columns=['component', 'explained_variance_ratio']
)
然后,绘制图表以得到如下图表。我相信你现在应该已经习惯了通过条形图绘制数据了:
从图表中可以看出,从第八个主成分开始,剩下的主成分携带的信息量不足 5%。
我们还可以循环不同的 n_components
值,然后在降维后的数据上训练模型,观察随着主成分数量的变化,准确率如何变化。我更信任这种方法,而不是依赖解释方差,因为它不依赖于主成分假设,并且将特征降维算法和分类器作为一个整体来评估。这一次,我将使用一个不同的算法:最近质心。
使用 PCA 的质心分类器
在下面的代码中,我们将尝试使用不同数量的主成分每次使用质心算法。请不要忘记在每次迭代中对特征进行缩放和转换,并记住将生成的 x
值存储在 x_train_embed
和 x_test_embed
中。我在这里使用了 StandardScaler
,以及 PCA 的 transform
方法来转换缩放后的数据:
from sklearn.neighbors import NearestCentroid
scores = []
for n_components in range(1, 33, 1):
# Scale and transform the features as before
clf = NearestCentroid(shrink_threshold=0.01)
clf.fit(x_train_embed, y_train)
y_test_pred = clf.predict(x_test_embed)
scores.append([n_components, accuracy_score(y_test, y_test_pred)])
绘制分数图表如下所示:
当我们在这个数据集上使用质心算法时,我们大致可以看出超过 15 个成分不会增加太多价值。通过交叉验证的帮助,我们可以选择能够提供最佳结果的确切成分数量。
从其成分恢复原始图像
一旦图像被降至其主成分,也可以将其恢复回来,如下所示。
- 首先,在使用 PCA 前,您必须对数据进行缩放:
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler(with_std=False)
x_train_scaled = scaler.fit_transform(x_train)
x_test_scaled = scaler.transform(x_test)
缩放后,您可以使用 32 个主成分来转换您的数据,如下所示。
- 然后,您可以使用
inverse_transform
方法在转换后恢复原始数据:
from sklearn.decomposition import PCA
embedder = PCA(n_components=32)
embedder.fit(x_train, y_train)
x_train_embed = embedder.transform(x_train_scaled)
x_test_embed = embedder.transform(x_test_scaled)
x_train_restored = embedder.inverse_transform(x_train_embed)
x_test_restored = embedder.inverse_transform(x_test_embed)
- 为了保持原始图像和恢复图像在同一比例上,我们可以使用
MinMaxScaler
,如下所示:
iscaler = MinMaxScaler((x_train.min(), x_train.max()))
x_train_restored = iscaler.fit_transform(x_train_restored)
x_test_restored = iscaler.fit_transform(x_test_restored)
这里,您可以看到一些数字与它们自身之间的比较,删除了不重要的成分。这些恢复的原始数据版本对分类器可能很有用,可以用它们替代训练和测试集,或者将它们作为训练集的附加样本:
- 最后,我在最近邻分类器中使用了
x_train_embed
和x_test_embed
替代了原始特征。我每次尝试了不同数量的 PCA 成分。以下图表中较暗的条形显示了能够产生最高准确度得分的 PCA 成分数量:
PCA 不仅帮助我们减少了特征数量和预测时间,同时还帮助我们获得了 98.9% 的得分。
查找最具信息量的像素
由于几乎所有数字都位于图像的中心,我们可以直觉地推断图像右侧和左侧的像素不包含有价值的信息。为了验证我们的直觉,我们将使用第四章中的特征选择算法,数据准备,来决定哪些像素最重要。在这里,我们可以使用互信息算法返回一个像素列表及其对应的重要性:
from sklearn.feature_selection import mutual_info_classif
mi = mutual_info_classif(x_train, y_train)
然后,我们使用前述信息去除了 75% 的像素:
percent_to_remove = 75
mi_threshold = np.quantile(mi, 0.01 * percent_to_remove)
informative_pixels = (mi >= mi_threshold).reshape((8, 8))
plt.imshow(informative_pixels, cmap='Greys')
plt.title(f'Pixels kept when {percent_to_remove}% removed')
在下图中,标记为黑色的像素是最具信息量的,其余的像素则是互信息算法认为不太重要的 75% 像素:
正如预期的那样,边缘处的像素信息量较少。既然我们已经识别出这些信息量较少的像素,我们可以通过移除这些像素来减少数据中的特征数量,具体如下:
from sklearn.feature_selection import SelectPercentile
percent_to_keep = 100 - percent_to_remove
selector = SelectPercentile(mutual_info_classif, percentile=percent_to_keep)
x_train_mi = selector.fit_transform(x_train, y_train)
x_test_mi = selector.transform(x_test)
在减少特征后的数据上训练分类器,我们得到了 94%的准确率。考虑到最近邻算法的复杂度以及其预测时间随着特征数量的增加而增长,我们可以理解一个略微不那么精确,但仅使用**25%**数据的算法的价值。
总结
图像在我们日常生活中无处不在。机器人需要计算机视觉来理解其周围环境。社交媒体上的大多数帖子都包含图片。手写文件需要图像处理才能被机器处理。这些以及更多的应用案例正是为什么图像处理成为机器学习从业者必须掌握的一项基本技能。在本章中,我们学习了如何加载图像并理解其像素。我们还学习了如何对图像进行分类,并通过降维来改善可视化效果和进一步的处理。
我们使用了最近邻算法进行图像分类和回归。这个算法允许我们在需要时插入自己的度量标准。我们还了解了其他算法,如半径邻居和最近质心。理解这些算法背后的概念及其差异在机器学习领域无处不在。稍后,我们将看到聚类和异常检测算法是如何借鉴这里讨论的概念的。除了这里讨论的主要算法,像距离度量和降维等概念也广泛存在。
由于图像处理的重要性,我们不会就此止步,因为我们将在第七章中进一步扩展这里获得的知识,神经网络——深度学习的到来,在那里我们将使用人工神经网络进行图像分类。
第六章:使用朴素贝叶斯分类器进行文本分类
“语言是一个自由创造的过程;它的规律和原则是固定的,但这些生成原则的运用方式是自由且无限变化的。甚至单词的解释和使用也涉及自由创造的过程。”
– 诺姆·乔姆斯基
并非所有信息都存在于表格中。从维基百科到社交媒体,成千上万的文字信息需要我们的计算机进行处理和提取。处理文本数据的机器学习子领域有着如文本挖掘和自然语言处理(NLP)等不同的名称。这些名称反映了该领域从多个学科继承而来。一方面,我们有计算机科学和统计学,另一方面,我们有语言学。我认为,在该领域初期,语言学的影响较大,但随着发展,实践者们更倾向于使用数学和统计工具,因为它们需要较少的人工干预,并且不需要人工将语言规则编入算法中:
“每次我解雇一个语言学家,我们的语音识别系统性能都会提升。”
– 弗雷德·杰里内克
话虽如此,了解事物随着时间的进展是如何发展的,避免直接跳入前沿解决方案,这一点至关重要。这使我们能够在意识到权衡取舍的基础上明智地选择工具。因此,我们将从处理文本数据开始,并以算法能够理解的格式呈现数据。这个预处理阶段对下游算法的性能有着重要影响。因此,我会确保阐明每种方法的优缺点。一旦数据准备好,我们将使用朴素贝叶斯分类器根据用户发送给多个航空公司服务的消息,检测不同 Twitter 用户的情感。
本章将涉及以下主题:
-
将句子拆分成词元
-
词元归一化
-
使用词袋模型表示词元
-
使用 n-gram 模型表示词元
-
使用 Word2Vec 表示词元
-
使用朴素贝叶斯分类器进行文本分类
将句子拆分成词元
“一个字接一个字,形成了力量。”
– 玛格丽特·阿特伍德
到目前为止,我们处理的数据要么是带有列作为特征的表格数据,要么是带有像素作为特征的图像数据。而在文本的情况下,问题变得不那么明确。我们应该使用句子、单词,还是字符作为特征?句子非常具体。例如,两篇维基百科文章中出现完全相同的句子的可能性非常小。因此,如果我们将句子作为特征,最终会得到大量的特征,这些特征的泛化能力较差。
另一方面,字符是有限的。例如,英语中只有 26 个字母。这种小的变化可能限制了单个字符携带足够信息的能力,无法让下游算法提取出有效的特征。因此,单词通常作为大多数任务的特征。
本章稍后我们会看到,尽管可以得到相当具体的标记,但现在让我们暂时仅把单词作为特征。最后,我们并不想局限于字典中的单词;Twitter 标签、数字和 URL 也可以从文本中提取并作为特征。因此,我们更倾向于使用 token 而不是 word 这个术语,因为 token 更为通用。将文本流分割成标记的过程称为分词,我们将在下一节中学习这个过程。
使用字符串分割进行分词
不同的分词方法会导致不同的结果。为了演示这些差异,让我们以以下三行文本为例,看看如何对它们进行分词。
在这里,我将文本行作为字符串写入并放入一个列表中:
lines = [
'How to tokenize?\nLike a boss.',
'Google is accessible via http://www.google.com',
'1000 new followers! #TwitterFamous',
]
一种明显的方法是使用 Python 内置的 split()
方法,如下所示:
for line in lines:
print(line.split())
当没有提供参数时,split()
会根据空格来分割字符串。因此,我们得到以下输出:
['How', 'to', 'tokenize?', 'Like', 'a', 'boss.']
['Google', 'is', 'accessible', 'via', 'http://www.google.com']
['1000', 'new', 'followers!', '#TwitterFamous']
你可能注意到,标点符号被保留为标记的一部分。问号被保留在 tokenize
的末尾,句号也附着在 boss
后面。井号标签由两个单词组成,但由于它们之间没有空格,它被作为一个整体标记保留,并带有前导的井号符号。
使用正则表达式进行分词
我们还可以使用正则表达式将字母和数字序列视为标记,并相应地分割我们的句子。这里使用的模式 "\w+"
表示任何一个或多个字母数字字符或下划线的序列。编译我们的模式会得到一个正则表达式对象,我们可以用它来进行匹配。最后,我们遍历每一行并使用正则表达式对象将其拆分为标记:
import re
_token_pattern = r"\w+"
token_pattern = re.compile(_token_pattern)
for line in lines:
print(token_pattern.findall(line))
这将给出以下输出:
['How', 'to', 'tokenize', 'Like', 'a', 'boss']
['Google', 'is', 'accessible', 'via', 'http', 'www', 'google', 'com']
['1000', 'new', 'followers', 'TwitterFamous']
现在,标点符号已被去除,但 URL 被分割成了四个标记。
Scikit-learn 默认使用正则表达式进行分词。然而,r"(?u)\b\w\w+\b"
这个模式被用来代替 r"\w+"
。这个模式会忽略所有标点符号和短于两个字母的单词。因此,“a” 这个词会被省略。你仍然可以通过提供自定义模式来覆盖默认模式。
使用占位符进行分词前的处理
为了解决前面的问题,我们可以决定在对句子进行分词之前,将数字、URL 和标签(hashtags)替换为占位符。如果我们不在意区分它们的内容,这样做是有用的。对我来说,URL 可能只是一个 URL,无论它指向哪里。以下函数将输入转换为小写字母,然后将找到的任何 URL 替换为_url_
占位符。类似地,它将标签和数字转换为相应的占位符。最后,输入根据空白字符进行分割,并返回结果的词元:
_token_pattern = r"\w+"
token_pattern = re.compile(_token_pattern)
def tokenizer(line):
line = line.lower()
line = re.sub(r'http[s]?://[\w\/\-\.\?]+','_url_', line)
line = re.sub(r'#\w+', '_hashtag_', line)
line = re.sub(r'\d+','_num_', line)
return token_pattern.findall(line)
for line in lines:
print(tokenizer(line))
这给我们带来了以下输出:
['how', 'to', 'tokenize', 'like', 'a', 'boss']
['google', 'is', 'accessible', 'via', '_url_']
['_num_', 'new', 'followers', '_hashtag_']
如你所见,新的占位符告诉我们第二个句子中存在一个 URL,但它并不关心该 URL 指向哪里。如果我们有另一个包含不同 URL 的句子,它也会得到相同的占位符。数字和标签也是一样的。
根据你的使用情况,如果你的标签包含你不想丢失的信息,这种方法可能并不理想。同样,这是一个你必须根据具体应用做出的权衡。通常,你可以直观地判断哪种技术更适合当前问题,但有时,评估经过多次分词技术后的模型,可能是唯一判断哪种方法更合适的方式。最后,在实际应用中,你可能会使用NLTK和spaCy等库来对文本进行分词。它们已经在后台实现了必要的正则表达式。在本章稍后的部分,我们将使用 spaCy。
请注意,我在处理句子之前将其转换为小写字母。这被称为归一化。如果没有归一化,首字母大写的单词和它的小写版本会被视为两个不同的词元。这不是理想的,因为Boy和boy在概念上是相同的,因此通常需要进行归一化。Scikit-learn 默认会将输入文本转换为小写字母。
将文本向量化为矩阵
在文本挖掘中,一个数据集通常被称为语料库。其中的每个数据样本通常被称为文档。文档由词元组成,一组不同的词元被称为词汇表。将这些信息放入矩阵中称为向量化。在接下来的章节中,我们将看到我们可以获得的不同类型的向量化方法。
向量空间模型
我们仍然缺少我们心爱的特征矩阵,在这些矩阵中,我们期望每个词元(token)有自己的列,每个文档由单独的一行表示。这种文本数据的表示方式被称为向量空间模型。从线性代数的角度来看,这种表示中的文档被视为向量(行),而不同的词项是该空间的维度(列),因此称为向量空间模型。在下一节中,我们将学习如何将文档向量化。
词袋模型
我们需要将文档转换为标记,并将它们放入向量空间模型中。此处可以使用CountVectorizer
对文档进行标记化并将其放入所需的矩阵中。在这里,我们将使用我们在上一节创建的分词器。像往常一样,我们导入并初始化CountVectorizer
,然后使用其fit_transform
方法来转换我们的文档。我们还指定希望使用我们在上一节构建的分词器:
from sklearn.feature_extraction.text import CountVectorizer
vec = CountVectorizer(lowercase=True, tokenizer=tokenizer)
x = vec.fit_transform(lines)
返回的矩阵中大部分单元格都是零。为了节省空间,它被保存为稀疏矩阵;然而,我们可以使用其todense()
方法将其转换为稠密矩阵。向量化器保存了遇到的词汇表,可以使用get_feature_names()
来检索。通过这些信息,我们可以将x
转换为 DataFrame,如下所示:
pd.DataFrame(
x.todense(),
columns=vec.get_feature_names()
)
这给了我们以下矩阵:
每个单元格包含每个标记在每个文档中出现的次数。然而,词汇表没有遵循任何顺序;因此,从这个矩阵中无法判断每个文档中标记的顺序。
不同的句子,相同的表示
取这两句话,它们有相反的意思:
flight_delayed_lines = [
'Flight was delayed, I am not happy',
'Flight was not delayed, I am happy'
]
如果我们使用计数向量化器来表示它们,我们将得到以下矩阵:
如你所见,句子中标记的顺序丢失了。这就是为什么这种方法被称为词袋模型(bag of words)——结果就像一个袋子,单词只是被放入其中,没有任何顺序。显然,这使得无法分辨哪一个人是开心的,哪一个不是。为了解决这个问题,我们可能需要使用n-grams,正如我们将在下一节中所做的那样。
N-grams
与其将每个术语视为一个标记,我们可以将每两个连续术语的组合视为一个单独的标记。我们要做的就是将CountVectorizer
中的ngram_range
设置为(2,2)
,如下所示:
from sklearn.feature_extraction.text import CountVectorizer
vec = CountVectorizer(ngram_range=(2,2))
x = vec.fit_transform(flight_delayed_lines)
使用与上一节相似的代码,我们可以将结果的x
放入 DataFrame 中并得到以下矩阵:
现在我们可以知道谁是开心的,谁不是。当使用词对时,这被称为大 ram(bigrams)。我们还可以使用 3-gram(由三个连续单词组成),4-gram 或任何其他数量的 gram。将ngram_range
设置为(1,1)将使我们回到原始表示形式,其中每个单独的单词是一个标记,这就是单 gram(unigrams)。我们还可以通过将ngram_range
设置为(1,2)来混合单 gram 和大 gram。简而言之,这个范围告诉分词器用于我们 n-gram 的最小值和最大值n。
如果你将n设置为一个较高的值——比如 8——这意味着八个单词的序列将被当作标记。那么,你认为一个包含八个单词的序列在你的数据集中出现的概率有多大?大概率是它只会在训练集中出现一次,而在测试集中从未出现过。这就是为什么n通常设置为 2 到 3 之间的数值,并且有时会使用一些 unigram 来捕捉稀有词汇。
使用字符代替单词
到目前为止,单词一直是我们文本宇宙中的原子。然而,有些情况可能需要我们基于字符来进行文档的标记化。在单词边界不明确的情况下,比如标签和 URL,使用字符作为标记可能会有所帮助。自然语言的字符频率通常不同。字母e是英语中使用最频繁的字符,字符组合如th、er和on也非常常见。其他语言,如法语和荷兰语,也有不同的字符频率。如果我们的目标是基于语言来分类文档,使用字符而不是单词可能会派上用场。
同样的CountVectorizer
可以帮助我们将文档标记化为字符。我们还可以将其与n-grams
设置结合,以获取单词中的子序列,如下所示:
from sklearn.feature_extraction.text import CountVectorizer
vec = CountVectorizer(analyzer='char', ngram_range=(4,4))
x = vec.fit_transform(flight_delayed_lines)
我们可以像之前一样将结果x
放入 DataFrame 中,从而得到如下矩阵:
现在我们所有的标记都由四个字符组成。如你所见,空格也被视作字符。使用字符时,通常会选择更高的n值。
使用 TF-IDF 捕捉重要词汇
我们在这里借鉴的另一个学科是信息检索领域。它是负责运行搜索引擎算法的领域,比如 Google、Bing 和 DuckDuckGo。
现在,看看下面这段引文:
“从语言学的角度来看,你真的不能对‘一个节目就是一个节目’这一概念提出太多反对意见。”
– 沃尔特·贝克尔
linguistic和that这两个词在前述引用中都出现过一次。然而,如果我们在互联网上搜索这段引文,我们只会关注linguistic这个词,而不是that这个词。我们知道,尽管它只出现了一次,和that出现的次数一样多,但它更为重要。show这个词出现了三次。从计数向量化器的角度来看,它应该比linguistic包含更多的信息。我猜你也不同意向量化器的看法。这些问题从根本上来说是词频-逆文档频率(TF-IDF)的存在原因。IDF 部分不仅涉及根据单词在某个文档中出现的频率来加权单词的值,还会在这些单词在其他文档中非常常见时对它们的权重进行折扣。that这个词在其他文档中如此常见,以至于它不应该像linguistic一样被赋予那么高的权重。此外,IDF 使用对数尺度来更好地表示一个词根据它在文档中的频率所携带的信息。*
*我们使用以下三个文档来演示 TF-IDF 是如何工作的:
lines_fruits = [
'I like apples',
'I like oranges',
'I like pears',
]
TfidfVectorizer
的接口与CountVectorizer
几乎完全相同:
from sklearn.feature_extraction.text import TfidfVectorizer
vec = TfidfVectorizer(token_pattern=r'\w+')
x = vec.fit_transform(lines_fruits)
这是两种向量化器输出的并排比较:
如你所见,与CountVectorizer
不同,TfidfVectorizer
并没有对所有单词进行平等对待。相比于其他出现在所有三句话中的不太有信息量的词,更多的强调被放在了水果名称上。
CountVectorizer
和**TfidfVectorizer
都有一个名为stop_words
的参数。它可以用来指定需要忽略的词元。你可以提供自己的不太有信息量的词列表,例如a**、an和the。你也可以提供english
关键字来指定英语中常见的停用词。话虽如此,需要注意的是,一些词对于某个任务来说可能有信息量,但对另一个任务则可能没有。此外,IDF 通常会自动完成你需要它做的工作,并且给非信息性词语赋予较低的权重。这就是为什么我通常不手动去除停用词,而是尝试使用TfidfVectorizer
、特征选择和正则化优先的方法。******
*******除了它的原始用途,TfidfVectorizer
通常作为文本分类的预处理步骤。然而,当需要对较长的文档进行分类时,它通常能给出不错的结果。对于较短的文档,它可能会产生嘈杂的转化,建议在这种情况下尝试使用CountVectorizer
。
在一个基础的搜索引擎中,当有人输入查询时,它会通过 TF-IDF 转换为与所有待搜索文档存在于同一向量空间中的形式。一旦查询和文档作为向量存在于同一空间中,就可以使用简单的距离度量方法,如余弦距离,来查找与查询最接近的文档。现代搜索引擎在这个基础概念上有所变化,但这是构建信息检索理解的良好基础。
使用词嵌入表示意义
由于文档是由词元组成的,它们的向量表示基本上是包含的词元向量之和。正如我们之前看到的,I like apples文档通过CountVectorizer
被表示为向量[1,1,1,0,0]:
从这种表示方式出发,我们还可以推断出I、like、apples和oranges分别由以下四个五维向量表示:[0,1,0,0,0],[0,0,1,0,0],[1,0,0,0,0]和[0,0,0,1,0]。我们有一个五维空间,基于我们五个词的词汇表。每个词在一个维度上的值为 1,其他四个维度上的值为 0。从线性代数的角度来看,所有五个词是正交的(垂直的)。然而,apples、pears和oranges都是水果,在概念上它们有一定的相似性,但这种相似性并没有被这个模型捕捉到。因此,我们理想的做法是使用相互接近的向量来表示它们,而不是这些正交的向量。顺便提一下,TfidfVectorizer
也存在类似问题***。*** 这促使研究人员提出了更好的表示方法,而词嵌入如今成为自然语言处理领域的热门技术,因为它比传统的向量化方法更好地捕捉了意义。在下一节中,我们将了解一种流行的嵌入技术——Word2Vec。
Word2Vec
不深入细节,Word2Vec 使用神经网络从上下文中预测单词,也就是说,从单词的周围词汇中进行预测。通过这种方式,它学习了更好的单词表示,并且这些表示包含了它们所代表的单词的意义。与前面提到的向量化方法不同,单词表示的维度与我们词汇表的大小没有直接关系。我们可以选择嵌入向量的长度。一旦每个单词被表示为一个向量,文档的表示通常是所有单词向量的和。平均值也是一个替代选择,而不是求和。
由于我们向量的大小与我们处理的文档的词汇量无关,研究人员可以重新使用未专门为他们特定问题训练的预训练 Word2Vec 模型。这种重新使用预训练模型的能力被称为迁移学习。一些研究人员可以使用昂贵的机器在大量文档上训练嵌入,并发布得到的向量供全世界使用。然后,下次我们处理特定的自然语言处理任务时,我们所需要做的就是获取这些向量并用它们来表示我们新的文档。spaCy (spacy.io/
)是一个开源软件库,提供了不同语言的词向量。
在接下来的几行代码中,我们将安装 spaCy,下载它的语言模型数据,并使用它将单词转换为向量:
- 要使用 spaCy,我们可以安装这个库并通过运行以下命令在终端中下载其英语预训练模型:
pip install spacy
python -m spacy download en_core_web_lg
- 然后,我们可以将下载的向量分配给我们的五个单词,如下所示:
import spacy
nlp = spacy.load('en_core_web_lg')
terms = ['I', 'like', 'apples', 'oranges', 'pears']
vectors = [
nlp(term).vector.tolist() for term in terms
]
- 这是苹果的表示:
# pd.Series(vectors[terms.index('apples')]).rename('apples')
0 -0.633400
1 0.189810
2 -0.535440
3 -0.526580
...
296 -0.238810
297 -1.178400
298 0.255040
299 0.611710
Name: apples, Length: 300, dtype: float64
我曾承诺你,苹果、橙子和梨的表示不会像CountVectorizer
那样正交。然而,使用 300 个维度时,我很难直观地证明这一点。幸运的是,我们已经学会了如何计算两个向量之间的余弦角度。正交向量之间的角度应该是 90°,其余弦值为 0。而两个方向完全相同的向量之间的零角度的余弦值为 1。
在这里,我们计算了来自 spaCy 的五个向量之间的余弦相似度。我使用了一些 pandas 和 seaborn 的样式,使数字更清晰:
import seaborn as sns
from sklearn.metrics.pairwise import cosine_similarity
cm = sns.light_palette("Gray", as_cmap=True)
pd.DataFrame(
cosine_similarity(vectors),
index=terms, columns=terms,
).round(3).style.background_gradient(cmap=cm)
然后,我在下面的 DataFrame 中展示了结果:
显然,新的表示方法理解到水果名称之间的相似度远高于它们与像I和like这样的词的相似度。它还认为苹果和梨非常相似,而橙子则不然。
你可能注意到,Word2Vec 存在与一元词相同的问题;词语的编码并没有太多关注它们的上下文。在句子“I will read a book”和“I will book a flight”中,单词“book”的表示是一样的。这就是为什么像语言模型嵌入(ELMo)、双向编码器表示从变换器(BERT)以及 OpenAI 最近的GPT-3等新技术现在越来越受欢迎的原因,因为它们尊重词语的上下文。我预计它们很快会被更多的库所采用,供大家轻松使用。
嵌入概念现在被各地的机器学习从业者回收并重新利用。除了在自然语言处理中的应用外,它还用于特征降维和推荐系统。例如,每当顾客将商品添加到在线购物车时,如果我们将购物车视为一个句子,将商品视为单词,那么我们就得到了商品的嵌入 (Item2Vec) 。这些商品的新表示可以轻松地插入到下游分类器或推荐系统中。
在进入文本分类之前,我们需要先停下来花一些时间了解我们将要使用的分类器——朴素贝叶斯分类器。
理解朴素贝叶斯
朴素贝叶斯分类器通常用于文本数据的分类。在接下来的部分中,我们将看到其不同的变种,并学习如何配置它们的参数。但首先,为了理解朴素贝叶斯分类器,我们需要先了解托马斯·贝叶斯在 18 世纪发表的贝叶斯定理。
贝叶斯规则
讨论分类器时,我们可以使用条件概率P(y|x)来描述某个样本属于某个类别的概率。这是给定其特征x的情况下,样本属于类别y的概率。管道符号(|)是我们用来表示条件概率的符号,即给定x的情况下的y。贝叶斯规则可以用以下公式将这种条件概率表达为P(x|y)、P(x)和P(y):
通常,我们忽略方程中的分母部分,将其转换为如下比例:
一个类别的概率,P(y),称为先验概率。它基本上是所有训练样本中属于某一类别的样本数。条件概率,P(x|y),称为似然度。它是我们从训练样本中计算出来的。一旦这两个概率在训练时已知,我们就可以利用它们来预测新样本属于某一类别的概率,即预测时的P(y|x),也称为后验概率。计算方程中的似然度部分并不像我们预期的那么简单。因此,在接下来的部分中,我们将讨论为了简化这一计算,我们可以做出哪些假设。
朴素地计算似然度
一个数据样本由多个特征构成,这意味着在实际应用中,P(x|y)中的x部分由x[1]、x[2]、x[3]、… x[k]构成,其中k是特征的数量。因此,条件概率可以表示为P(x[1], x[2], x[3], … x[k]|y)。实际上,这意味着我们需要为x的所有可能组合计算该条件概率。这么做的主要缺点是我们模型的泛化能力不足。
让我们通过以下的玩具示例来澄清这一点:
文本 | 文本是否表明作者喜欢水果? |
---|---|
我喜欢苹果 | 是 |
我喜欢橙子 | 是 |
我讨厌梨 | 否 |
如果前面的表格是我们的训练数据,第一个样本的似然概率,P(x|y),就是给定目标是时,看到三个词我、喜欢和苹果一起出现的概率。同理,第二个样本的概率是给定目标是时,看到三个词我、喜欢和橙子一起出现的概率。第三个样本也是如此,只不过目标是否而不是是。现在,假设我们给定一个新样本,我讨厌苹果。问题是,我们之前从未见过这三个词一起出现。你可能会说:“但是我们以前见过每个单独的词,只是分开出现!”这是正确的,但我们的公式只关心词的组合。它无法从每个单独的特征中学到任何东西。
你可能记得在第四章中,准备你的数据,P(x[1], x[2], x[3], … x[k]|y) 只有在 x[1], x[2], x[3], … x[k] 互相独立时,才能表示为 P(x[1]|y) P(x[2]|y)x[3]* … * P(x[k]|y)*。它们的独立性并非我们可以确定的,但我们仍然做出了这个朴素的假设,以使模型更具普适性。由于这一假设,并且由于我们处理的是独立的单词,现在我们可以了解关于短语我讨厌苹果的一些信息,尽管我们以前从未见过它。这种虽然朴素但有用的独立性假设,正是给分类器命名时加上“朴素”前缀的原因。
朴素贝叶斯实现
在 scikit-learn 中,有多种朴素贝叶斯实现方式。
-
多项式朴素贝叶斯分类器是文本分类中最常用的实现。它的实现方式与我们在前一节看到的最为相似。
-
伯努利朴素贝叶斯分类器假设特征是二元的。在伯努利版本中,我们关注的不是每个文档中某个词出现的次数,而是该词是否存在。计算似然的方式明确惩罚文档中未出现的词汇,在一些数据集上,尤其是短文档的数据集上,它可能表现得更好。
高斯朴素贝叶斯用于连续特征。它假设特征呈正态分布,并使用最大似然估计计算似然概率。该实现适用于文本分析之外的其他情况。*
此外,你还可以在 scikit-learn 用户指南中阅读关于另外两个实现——互补朴素贝叶斯和类别朴素贝叶斯**的内容,链接为(scikit-learn.org/stable/modules/naive_bayes.html
)。
加性平滑
当在预测过程中出现训练时未见过的词汇时,我们将其概率设置为 0。这听起来很合乎逻辑,但鉴于我们天真的假设,这其实是一个有问题的决定。由于P(x[1], x[2], x[3], … x[k]|y) 等于 P(x[1]|y) P(x[2]|y)P(x[3]|y) … * P(x[k]|y),*将任何词汇的条件概率设为零,将导致整个 P(x[1], x[2], x[3], … x[k]|y) 被设置为零。为了避免这个问题,我们假设每个类别中都加入了一份包含所有词汇的文档。从概念上讲,这个新的假设性文档将从我们已见过的词汇中分配一部分概率质量,并将其重新分配给未见过的词汇。alpha
参数控制我们希望重新分配给未见过的词汇的概率质量。将 alpha
设置为 1 被称为拉普拉斯平滑,而将其设置为介于 0 和 1 之间的值则称为利德斯通平滑。
我发现在计算比率时,经常使用拉普拉斯平滑。除了防止我们出现除以零的情况外,它还帮助处理不确定性。让我通过以下两个例子进一步解释:
-
例子 1:10,000 人看到了一个链接,其中 9,000 人点击了它。显然,我们可以估算点击率为 90%。
-
例子 2:如果我们的数据中只有一个人,而且这个人看到了链接并点击了它,我们能有足够的信心说点击率是 100%吗?
在前面的例子中,如果我们假设有两个额外的用户,其中只有一个点击了链接,那么第一个例子的点击率将变为 9,001/10,002,仍然接近 90%。然而,在第二个例子中,我们将用 2 除以 3,这将得到 60%,而不是之前计算出的 100%。拉普拉斯平滑和利德斯通平滑可以与贝叶斯的思维方式联系起来。这两个用户,其中 50%的人点击了链接,代表了我们的先验信念。最初,我们了解的信息很少,所以我们假设点击率为 50%。现在,在第一个例子中,我们有足够的数据来推翻这个先验信念,而在第二个例子中,较少的数据点只能稍微调整先验。
现在先不谈理论 – 让我们用到目前为止学到的所有内容,来判断一些评论者是否对他们的观影体验感到满意。
使用朴素贝叶斯分类器进行文本分类
在本节中,我们将获取一组句子,并根据用户的情感对其进行分类。我们要判断该句子是带有积极情感还是消极情感。Dimitrios Kotzias 等人 为他们的研究论文《从群体到个体标签,利用深度特征》创建了这个数据集。他们从三个不同的网站收集了一组随机句子,并将每个句子标记为 1(积极情感)或 0(消极情感)。
数据集中总共有 2,745 个句子。在接下来的部分中,我们将下载数据集、预处理数据,并对其中的句子进行分类。
下载数据
你可以直接打开浏览器,将 CSV 文件下载到本地文件夹,并使用 pandas 将文件加载到数据框中。然而,我更喜欢使用 Python 来下载文件,而不是使用浏览器。我这么做不是因为我是极客,而是为了确保我的整个过程的可重现性,将其编写成代码。任何人都可以运行我的 Python 代码并得到相同的结果,而不需要阅读糟糕的文档文件,找到压缩文件的链接,并按照指示获取数据。
以下是下载所需数据的步骤:
- 首先,让我们创建一个文件夹来存储下载的数据。以下代码检查所需文件夹是否存在。如果不存在,它会在当前工作目录中创建该文件夹:
import os
data_dir = f'{os.getcwd()}/data'
if not os.path.exists(data_dir):
os.mkdir(data_dir)
- 然后我们需要使用
pip
安装requests
库,因为我们将使用它来下载数据:
pip install requests
- 然后,我们按照以下方式下载压缩数据:
import requests
url = 'https://archive.ics.uci.edu/ml/machine-learning-databases/00331/sentiment%20labelled%20sentences.zip'
response = requests.get(url)
- 现在,我们可以解压数据并将其存储到刚创建的数据文件夹中。我们将使用
zipfile
模块来解压数据。ZipFile
方法期望读取一个文件对象。因此,我们使用BytesIO
将响应内容转换为类似文件的对象。然后,我们将 zip 文件的内容提取到我们的文件夹中,如下所示:
import zipfile
from io import BytesIO
with zipfile.ZipFile(file=BytesIO(response.content), mode='r') as compressed_file:
compressed_file.extractall(data_dir)
- 现在,我们的数据已经写入到数据文件夹中的 3 个独立文件中,我们可以将这 3 个文件分别加载到 3 个数据框中。然后,我们可以将这 3 个数据框合并成一个单一的数据框,如下所示:
df_list = []
for csv_file in ['imdb_labelled.txt', 'yelp_labelled.txt', 'amazon_cells_labelled.txt']:
csv_file_with_path = f'{data_dir}/sentiment labelled sentences/{csv_file}'
temp_df = pd.read_csv(
csv_file_with_path,
sep="\t", header=0,
names=['text', 'sentiment']
)
df_list.append(temp_df)
df = pd.concat(df_list)
- 我们可以使用以下代码显示情感标签的分布:
explode = [0.05, 0.05]
colors = ['#777777', '#111111']
df['sentiment'].value_counts().plot(
kind='pie', colors=colors, explode=explode
)
如我们所见,两个类别大致相等。在进行任何分类任务之前,检查类别的分布是一种好习惯:
- 我们还可以使用以下代码显示一些示例句子,调整 pandas 的设置以显示更多字符:
pd.options.display.max_colwidth = 90
df[['text', 'sentiment']].sample(5, random_state=42)
我将random_state
设置为一个任意值,以确保我们得到相同的样本,如下所示:
准备数据
现在,我们需要为分类器准备数据以供使用:
- 像我们通常做的那样,我们首先将数据框分割为训练集和测试集。我将 40%的数据集用于测试,并将
random_state
设置为一个任意值,以确保我们都获得相同的随机分割:
from sklearn.model_selection import train_test_split
df_train, df_test = train_test_split(df, test_size=0.4, random_state=42)
- 然后,我们从情感列中获取标签,如下所示:
y_train = df_train['sentiment']
y_test = df_test['sentiment']
- 对于文本特征,让我们使用
CountVectorizer
来转换它们。我们将包括一元组、二元组和三元组。我们还可以通过将min_df
设置为3
来忽略稀有词,这样就可以排除出现在少于三个文档中的单词。这是去除拼写错误和噪声标记的一个有效做法。最后,我们可以去掉字母的重音并将其转换为ASCII
:
from sklearn.feature_extraction.text import CountVectorizer
vec = CountVectorizer(ngram_range=(1,3), min_df=3, strip_accents='ascii')
x_train = vec.fit_transform(df_train['text'])
x_test = vec.transform(df_test['text'])
- 最后,我们可以使用朴素贝叶斯分类器来分类我们的数据。我们为模型设置
fit_prior=True
,使其使用训练数据中类别标签的分布作为先验:
from sklearn.naive_bayes import MultinomialNB
clf = MultinomialNB(fit_prior=True)
clf.fit(x_train, y_train)
y_test_pred = clf.predict(x_test)
这次,我们的传统准确度得分可能不足以提供足够的信息。我们希望知道每个类别的准确性。此外,根据我们的使用案例,我们可能需要判断模型是否能够识别所有的负面推文,即使这样做的代价是错误地分类了一些正面推文。为了获得这些信息,我们需要使用精度
和召回率
得分。
精度、召回率和 F1 得分
在被分配到正类的样本中,实际上为正类的百分比就是该类别的精度。对于正面推文,分类器正确预测为正面的推文百分比就是该类别的召回率。如你所见,精度和召回率是按类别计算的。以下是我们如何用真实正例和假正例正式表达精度得分:
召回率得分是通过真实正例和假负例来表示的*:*
为了将前两个得分汇总为一个数字,可以使用F[1]得分。它通过以下公式将精度和召回率得分结合起来:
在这里,我们计算了我们的分类器的三项上述度量:
p, r, f, s = precision_recall_fscore_support(y_test, y_test_pred)
为了更清楚地说明,我将结果度量放入以下表格中。请记住,支持度仅仅是每个类别中的样本数量:
由于两个类别的大小几乎相等,因此得分是相等的。如果类别不平衡,通常会看到某个类别的精度或召回率高于另一个类别。
由于这些度量是按类别标签计算的,我们还可以获得它们的宏观平均值。在此示例中,宏观平均精度得分将是0.81和0.77的平均值,即0.79。另一方面,微观平均是基于总体的真实正例、假正例和假负例样本数量来计算这些得分的。
流水线
在前几章中,我们使用网格搜索来找到估计器的最佳超参数。现在,我们有多个东西要同时优化。一方面,我们想优化朴素贝叶斯的超参数,另一方面,我们还想优化预处理步骤中使用的向量化器的参数。由于网格搜索只期望一个对象,scikit-learn 提供了一个pipeline
封装器,我们可以在其中将多个转换器和估计器组合成一个。
顾名思义,管道是由一系列顺序步骤组成的。在这里,我们从CountVectorizer
开始,MultinomialNB
作为第二步也是最后一步:
from sklearn.pipeline import Pipeline
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.naive_bayes import MultinomialNB
pipe = Pipeline(steps=[
('CountVectorizer', CountVectorizer()),
('MultinomialNB', MultinomialNB())]
)
除了最后一步的对象外,其他所有对象都应该是transformers
,即它们应具有fit
、transform
和fit_transform
方法。最后一步的对象应为estimator
,意味着它应该具有fit
和predict
方法。你还可以构建自定义的转换器和估计器,并在管道中使用,只要它们具有预期的方法。
现在我们的管道已经准备好,我们可以将其插入GridSearchCV
中,以寻找最佳超参数。
针对不同得分的优化
“你所衡量的,便是你所管理的。”
——彼得·德鲁克
当我们之前使用GridSearchCV
时,我们没有指定要优化的超参数的度量标准。默认情况下使用分类器的准确度。或者,你也可以选择优化超参数的精确度得分或召回率得分。在这里,我们将设置网格搜索以优化宏精确度得分。
我们从设置要搜索的不同超参数开始。由于我们在这里使用管道,因此我们需要为每个超参数添加步骤名称的前缀,以便管道将参数分配给正确的步骤:
param_grid = {
'CountVectorizer__ngram_range': [(1,1), (1,2), (1,3)],
'MultinomialNB__alpha': [0.1, 1],
'MultinomialNB__fit_prior': [True, False],
}
默认情况下,贝叶斯规则中的先验P(y)
是根据每个类别中的样本数设置的。然而,我们可以通过设置fit_prior=False
将其设置为所有类别的常量。
在这里,我们运行GridSearchCV
,并告诉它我们最关心的是精确度:
from sklearn.model_selection import GridSearchCV
search = GridSearchCV(pipe, param_grid, scoring='precision_macro', n_jobs=-1)
search.fit(df_train['text'], y_train)
print(search.best_params_)
这为我们提供了以下超参数:
-
ngram_range
: (1, 3) -
alpha
: 1 -
fit_prior
: False
我们得到了 80.5%的宏精确度和 80.5%的宏召回率。
由于类别分布平衡,预计先验不会增加太多价值。我们还获得了相似的精确度和召回率得分。因此,现在重新运行网格搜索以优化召回率没有意义,我们无论如何都会得到相同的结果。然而,当处理高度不平衡的类别时,事情可能会有所不同,这时你可能希望通过牺牲其他类别的效果来最大化某一类别的召回率。
在下一节中,我们将使用词嵌入来表示我们的标记。让我们看看这种迁移学习方法是否能帮助我们的分类器表现得更好。
创建自定义转换器
在结束本章之前,我们还可以基于Word2Vec
嵌入创建一个自定义变换器,并在我们的分类管道中使用它,而不是使用CountVectorizer
。为了能够在管道中使用我们的自定义变换器,我们需要确保它具备fit
、transform
和fit_transform
方法。
这是我们新的变换器,我们将其命名为WordEmbeddingVectorizer
:
import spacy
class WordEmbeddingVectorizer:
def __init__(self, language_model='en_core_web_md'):
self.nlp = spacy.load(language_model)
def fit(self):
pass
def transform(self, x, y=None):
return pd.Series(x).apply(
lambda doc: self.nlp(doc).vector.tolist()
).values.tolist()
def fit_transform(self, x, y=None):
return self.transform(x)
这里的fit
方法是无效的——它什么也不做,因为我们使用的是 spaCy 的预训练模型。我们可以按照以下方式使用新创建的变换器:
vec = WordEmbeddingVectorizer()
x_train_w2v = vec.transform(df_train['text'])
我们可以使用这个变换器与其他分类器一起使用,而不仅仅是朴素贝叶斯分类器,例如LogisticRegression
或Multi-layer Perceptron
。
pandas 中的apply
函数可能会很慢,尤其是在处理大量数据时。我喜欢使用一个叫做tqdm
的库,它可以让我将apply()
方法替换为progress_apply()
,这样在运行时就会显示进度条。导入库后,你只需运行tqdm.pandas()
;这会将progress_apply()
方法添加到 pandas 的 Series 和 DataFrame 对象中。顺便说一句,tqdm
这个词在阿拉伯语中意味着进度。
总结
就我个人而言,我发现自然语言处理领域非常令人兴奋。我们人类的绝大多数知识都包含在书籍、文档和网页中。了解如何借助机器学习自动提取这些信息并组织它们,对我们的科学进步和自动化事业至关重要。这就是为什么多个科学领域,如信息检索、统计学和语言学,相互借鉴思想并试图从不同角度解决同一个问题。在本章中,我们也借鉴了这些领域的思想,并学习了如何将文本数据表示为适合机器学习算法的格式。我们还了解了 scikit-learn 提供的工具,以帮助构建和优化端到端解决方案。我们还遇到了转移学习等概念,并能够无缝地将 spaCy 的语言模型集成到 scikit-learn 中。
从下一章开始,我们将处理一些稍微高级的话题。在下一章中,我们将学习人工神经网络(多层感知机)。这是当今非常热门的话题,理解其主要概念对于任何想深入学习深度学习的人来说都很有帮助。由于神经网络通常用于图像处理,我们将借此机会,在第五章《最近邻图像处理》中继续扩展我们的图像处理知识。
第二部分:高级监督学习
本节包含如何处理不平衡数据,以及如何优化算法以实现实际的偏差/方差折衷的相关信息。它还深入探讨了更高级的算法,如人工神经网络和集成方法。
本节包含以下章节:
第七章:神经网络 – 深度学习的到来
阅读新闻文章或遇到一些误用深度学习这一术语来代替机器学习的情况并不罕见。这是因为深度学习作为机器学习的一个子领域,已经在解决许多以前无法解决的图像处理和自然语言处理问题上取得了巨大的成功。这种成功使得许多人将这个子领域与其父领域混淆。
深度学习这一术语指的是深度人工神经网络(ANNs)。后者的概念有多种形式和形态。在本章中,我们将讨论一种前馈神经网络的子集,称为多层感知器(MLP)。它是最常用的类型之一,并由 scikit-learn 实现。顾名思义,它由多层组成,且它是一个前馈网络,因为其层之间没有循环连接。层数越多,网络就越深。这些深度网络可以有多种形式,如MLP、卷积神经网络(CNNs)或长短期记忆网络(LSTM)。后两者并未由 scikit-learn 实现,但这并不会妨碍我们讨论 CNN 的基本概念,并使用科学 Python 生态系统中的工具手动模拟它们。
在本章中,我们将讨论以下主题:
-
了解 MLP
-
分类衣物
-
解开卷积的谜团
-
MLP 回归器
了解 MLP
当学习一种新的算法时,你可能会因为超参数的数量而感到灰心,并且发现很难决定从哪里开始。因此,我建议我们从回答以下两个问题开始:
-
该算法的架构是如何设计的?
-
该算法是如何训练的?
在接下来的章节中,我们将逐一回答这两个问题,并了解相应的超参数。
理解算法的架构
幸运的是,我们在第三章中获得的关于线性模型的知识,利用线性方程做决策,将在这里给我们一个良好的开端。简而言之,线性模型可以在以下图示中概述:
每个输入特征(x[i])都会乘以一个权重(w[i]),这些乘积的总和就是模型的输出(y)。此外,我们有时还会添加一个额外的偏置(阈值)及其权重。然而,线性模型的一个主要问题是它们本质上是线性的(显然!)。此外,每个特征都有自己的权重,而不考虑它的邻居。这种简单的架构使得模型无法捕捉到特征之间的任何交互。因此,你可以将更多的层堆叠在一起,如下所示:
这听起来像是一个潜在的解决方案;然而,根据简单的数学推导,这些乘法和加法组合仍然可以简化为一个线性方程。就好像所有这些层根本没有任何效果一样。因此,为了达到预期效果,我们希望在每次加法后应用非线性变换。这些非线性变换被称为激活函数,它们将模型转化为非线性模型。让我们看看它们如何融入模型中,然后我会进一步解释:
该模型有一个包含两个隐藏节点的单一隐藏层,如框内所示。在实际应用中,你可能会有多个隐藏层和多个节点。前述的激活函数应用于隐藏节点的输出。这里,我们使用了修正线性单元(ReLU)作为激活函数;对于负值,它返回0
,而对正值则保持不变。除了relu
函数,identity
、logistic
和tanh
激活函数也支持用于隐藏层,并且可以通过activation
超参数进行设置。以下是这四种激活函数的表现形式:
如前所述,由于identity
函数不会对其输入进行任何非线性变换,因此很少使用,因为它最终会将模型简化为一个线性模型。它还存在着梯度恒定的问题,这对用于训练的梯度下降算法帮助不大。因此,relu
函数通常是一个不错的非线性替代方案。它是当前的默认设置,也是一个不错的首选;logistic
或tanh
激活函数则是下一个可选方案。
输出层也有其自己的激活函数,但它起着不同的作用。如果你还记得第三章,《使用线性方程做决策》,我们使用logistic
函数将线性回归转变为分类器——也就是逻辑回归。输出的激活函数在这里起着完全相同的作用。下面列出了可能的输出激活函数及其对应的应用场景:
-
Identity 函数:在使用
MLPRegressor
进行回归时设置 -
Logistic 函数:在使用
MLPClassifier
进行二分类时设置 -
Softmax 函数:在使用
MLPClassifier
区分三类或更多类别时设置
我们不手动设置输出激活函数;它们会根据是否使用MLPRegressor
或MLPClassifier
以及后者用于分类的类别数自动选择。
如果我们看一下网络架构,很明显,另一个需要设置的重要超参数是隐藏层的数量以及每层的节点数。这个设置通过hidden_layer_sizes
超参数来完成,它接受元组类型的值。为了实现前面图中的架构——也就是一个隐藏层,包含两个节点——我们将hidden_layer_sizes
设置为2
。将其设置为(10, 10, 5)
则表示有三个隐藏层,前两个层每层包含 10 个节点,而第三层包含 5 个节点。
*## 训练神经网络
“心理学家告诉我们,要从经验中学习,必须具备两个要素:频繁的练习和即时的反馈。”
– 理查德·塞勒
大量研究人员的时间花费在改进他们神经网络的训练上。这也反映在与训练算法相关的超参数数量上。为了更好地理解这些超参数,我们需要研究以下的训练工作流程:
-
获取训练样本的子集。
-
将数据通过网络,进行预测。
-
通过比较实际值和预测值来计算训练损失。
-
使用计算出的损失来更新网络权重。
-
返回到步骤 1获取更多样本,如果所有样本都已使用完,则反复遍历训练数据,直到训练过程收敛。
逐步执行这些步骤,你可以看到在第一阶段需要设置训练子集的大小。这就是batch_size
参数所设置的内容。正如我们稍后会看到的,你可以从一次使用一个样本,到一次使用整个训练集,再到介于两者之间的任何方式。第一步和第二步是直接的,但第三步要求我们知道应该使用哪种损失函数。至于可用的损失函数,当使用 scikit-learn 时,我们没有太多选择。在进行分类时,对数损失函数会自动为我们选择,而均方误差则是回归任务的默认选择。第四步是最棘手的部分,需要设置最多的超参数。我们计算损失函数相对于网络权重的梯度。
这个梯度告诉我们应该朝哪个方向移动,以减少损失函数。换句话说,我们利用梯度更新权重,希望通过迭代降低损失函数至最小值。负责这一操作的逻辑被称为求解器(solver)。不过,求解器值得单独一节,稍后会详细介绍。最后,我们多次遍历训练数据的次数被称为“迭代次数”(epochs),它通过max_iter
超参数来设置。如果模型停止学习,我们也可以决定提前停止(early_stopping
)。validation_fraction
、n_iter_no_change
和tol
这些超参数帮助我们决定何时停止训练。更多关于它们如何工作的内容将在下一节讨论。
配置求解器。
计算损失函数(也称为成本函数或目标函数)后,我们需要找到能够最小化损失函数的最优网络权重。在第三章的线性模型中,使用线性方程做决策,损失函数被选择为凸函数。正如下面的图形所示,凸函数有一个最小值,这个最小值既是全局最小值也是局部最小值。这使得在优化该函数时,求解器的工作变得简单。对于非线性神经网络,损失函数通常是非凸的,这就需要在训练过程中更加小心,因此在这里给予求解器更多的关注:
MLP 的支持求解器可以分为有限记忆Broyden–Fletcher–Goldfarb–Shanno(LBFGS)和梯度下降(随机梯度下降(SGD)和Adam)。在这两种变体中,我们希望从损失函数中随机选择一个点,计算其斜率(梯度),并使用它来确定下一步应该朝哪个方向移动。请记住,在实际情况中,我们处理的维度远远超过这里展示的二维图形。此外,我们通常无法像现在这样看到整个图形:
*** LBFGS算法同时使用斜率(一阶导数)和斜率变化率(二阶导数),这有助于提供更好的覆盖;然而,它在训练数据规模较大时表现不佳。训练可能非常缓慢,因此推荐在数据集较小的情况下使用该算法,除非有更强大的并行计算机来帮助解决。
- 梯度下降算法仅依赖于一阶导数。因此,需要更多的努力来帮助它有效地移动。计算出的梯度与
learning_rate
结合。这控制了每次计算梯度后,它的移动步长。移动过快可能会导致超过最小值并错过局部最小值,而移动过慢可能导致算法无法及时收敛。我们从由learning_rate_init
定义的速率开始。如果我们设置learning_rate='constant'
,初始速率将在整个训练过程中保持不变。否则,我们可以设置速率在每一步中减少(按比例缩放),或者仅在模型无法再继续学习时才减少(自适应)。
*** 梯度下降可以使用整个训练数据集计算梯度,使用每次一个样本(sgd
),或者以小批量的方式消耗数据(小批量梯度下降)。这些选择由batch_size
控制。如果数据集无法完全加载到内存中,可能会阻止我们一次性使用整个数据集,而使用小批量可能会导致损失函数波动。我们将在接下来的部分中实际看到这种效果。* 学习率的问题在于它不能适应曲线的形状,特别是我们这里只使用了一阶导数。我们希望根据当前曲线的陡峭程度来控制学习速度。使学习过程更智能的一个显著调整是动量
的概念。它根据当前和以前的更新来调整学习过程。sgd
求解器默认启用动量
,并且其大小可以通过momentum
超参数进行设置。adam
求解器将这一概念结合,并与为每个网络权重计算独立学习率的能力结合在一起。它通过beta_1
和beta_2
来参数化。通常它们的默认值分别为0.9
和0.999
。由于adam
求解器相比sgd
求解器需要更少的调整工作,因此它是默认的求解器。然而,如果正确调整,sgd
求解器也可以收敛到更好的解。* 最后,决定何时停止训练过程是另一个重要的决策。我们会多次遍历数据,直到达到max_iter
设置的上限。然而,如果我们认为学习进展不足,可以提前停止。我们通过tol
定义多少学习是足够的,然后可以立即停止训练过程,或者再给它一些机会(n_iter_no_change
),然后决定是否停止。此外,我们可以将训练集的一部分单独留出(validation_fraction
),用来更好地评估我们的学习过程。然后,如果我们设置early_stopping = True
,训练过程将在验证集的改进未达到tol
阈值并且已达到n_iter_no_change
个周期时停止。****
****现在我们对事情如何运作有了一个高层次的了解,我认为最好的前进方式是将所有这些超参数付诸实践,并观察它们在真实数据上的效果。在接下来的部分中,我们将加载一个图像数据集,并利用它来进一步了解前述的超参数。
分类服装项
在本节中,我们将根据衣物图像对服装项进行分类。我们将使用 Zalando 发布的一个数据集。Zalando 是一家总部位于柏林的电子商务网站。他们发布了一个包含 70,000 张服装图片的数据集,并附有相应标签。每个服装项都属于以下 10 个标签之一:
{ 0: 'T-shirt/top ', 1: 'Trouser ', 2: 'Pullover ', 3: 'Dress ', 4: 'Coat ', 5: 'Sandal ', 6: 'Shirt ', 7: 'Sneaker ', 8: 'Bag ', 9: 'Ankle boot' }
该数据已发布在 OpenML 平台上,因此我们可以通过 scikit-learn 中的内置下载器轻松下载它。
下载 Fashion-MNIST 数据集
OpenML 平台上的每个数据集都有一个特定的 ID。我们可以将这个 ID 传递给fetch_openml()
来下载所需的数据集,代码如下:
from sklearn.datasets import fetch_openml
fashion_mnist = fetch_openml(data_id=40996)
类别标签以数字形式给出。为了提取它们的名称,我们可以从描述中解析出以下内容:
labels_s = '0 T-shirt/top \n1 Trouser \n2 Pullover \n3 Dress \n4 Coat \n5 Sandal \n6 Shirt \n7 Sneaker \n8 Bag \n9 Ankle boot'
fashion_label_translation = {
int(k): v for k, v in [
item.split(maxsplit=1) for item in labels_s.split('\n')
]
}
def translate_label(y, translation=fashion_label_translation):
return pd.Series(y).apply(lambda y: translation[int(y)]).values
我们还可以创建一个类似于我们在第五章中创建的函数,使用最近邻的图像处理,来显示数据集中的图片:
def display_fashion(img, target, ax):
if len(img.shape):
w = int(np.sqrt(img.shape[0]))
img = img.reshape((w, w))
ax.imshow(img, cmap='Greys')
ax.set_title(f'{target}')
ax.grid(False)
上述函数除了matplotlib
坐标轴外,还期望接收一张图片和一个目标标签来显示该图片。我们将在接下来的章节中看到如何使用它。
准备分类数据
在开发模型并优化其超参数时,你需要多次运行模型。因此,建议你先使用较小的数据集以减少训练时间。一旦达到可接受的模型效果,就可以添加更多数据并进行最终的超参数调优。稍后,我们将看到如何判断手头的数据是否足够,以及是否需要更多样本;但现在,让我们先使用一个包含 10,000 张图片的子集。
我故意避免在从原始数据集进行采样时以及将采样数据拆分为训练集和测试集时设置任何随机状态。由于没有设置随机状态,你应该期望最终结果在每次运行中有所不同。我做出这个选择是因为我的主要目标是专注于底层概念,而不希望你过于纠结最终结果。最终,你在现实场景中处理的数据会因问题的不同而有所不同,我们在前面的章节中已经学会了如何通过交叉验证更好地理解模型性能的边界。所以,在这一章中,和本书中的许多其他章节一样,不必太担心提到的模型的准确率、系数或学习行为与你的结果有所不同。
我们将使用train_test_split()
函数两次。最初,我们将用它进行采样。之后,我们将再次使用它来执行将数据拆分为训练集和测试集的任务:
from sklearn.model_selection import train_test_split
fashion_mnist_sample = {}
fashion_mnist_sample['data'], _, fashion_mnist_sample['target'], _ = train_test_split(
fashion_mnist['data'], fashion_mnist['target'], train_size=10000
)
x, y = fashion_mnist_sample['data'], fashion_mnist_sample['target']
x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.2)
这里的像素值在0
和255
之间。通常,这样是可以的;然而,我们将要使用的求解器在数据落在更紧凑的范围内时收敛得更好。MinMaxScaler
将帮助我们实现这一点,如下所示,而StandardScaler
也是一个选择:
from sklearn.preprocessing import MinMaxScaler
scaler = MinMaxScaler()
x_train = scaler.fit_transform(x_train)
x_test = scaler.transform(x_test)
我们现在可以使用我们在上一节中创建的函数,将数字标签转换为名称:
translation = fashion_label_translation
y_train_translated = translate_label(y_train, translation=translation)
y_test_translated = translate_label(y_test, translation=translation)
如果你的原始标签是字符串格式,可以使用LabelEncoder
将其转换为数值:
from sklearn.preprocessing import LabelEncoder
le = LabelEncoder()
y_train_encoded = le.fit_transform(y_train_translated)
y_test_encoded = le.transform(y_test_translated)
最后,让我们使用以下代码查看这些图片的样子:
import random
fig, axs = plt.subplots(1, 10, figsize=(16, 12))
for i in range(10):
rand = random.choice(range(x_train.shape[0]))
display_fashion(x_train[rand], y_train_translated[rand], axs[i])
fig.show()
在这里,我们看到 10 张随机图片及其标签。我们循环显示 10 张随机图片,并使用我们之前创建的显示函数将它们并排展示:
现在数据已经准备好,接下来是时候看看超参数在实践中的效果了。
体验超参数的效果
在神经网络训练完成后,你可以检查它的权重(coefs_
)、截距(intercepts_
)以及损失函数的最终值(loss_
)。另外一个有用的信息是每次迭代后的计算损失(loss_curve_
)。这一损失曲线对于学习过程非常有帮助。
在这里,我们训练了一个神经网络,包含两个隐藏层,每个层有 100 个节点,并将最大迭代次数设置为500
。目前,我们将其他所有超参数保持默认值:
from sklearn.neural_network import MLPClassifier
clf = MLPClassifier(hidden_layer_sizes=(100, 100), max_iter=500)
clf.fit(x_train, y_train_encoded)
y_test_pred = clf.predict(x_test)
网络训练完成后,我们可以使用以下代码绘制损失曲线:
pd.Series(clf.loss_curve_).plot(
title=f'Loss Curve; stopped after {clf.n_iter_} epochs'
)
这将给我们如下图:
尽管算法被告知最多继续学习500
个周期,但它在第 107^(次)周期后停止了。n_iter_no_change
的默认值是10
个周期。这意味着,自第 97^(次)周期以来,学习率没有足够改善,因此网络在 10 个周期后停了下来。请记住,默认情况下early_stopping
是False
,这意味着这个决策是在不考虑默认设置的10%
验证集的情况下做出的。如果我们希望使用验证集来决定是否提前停止,我们应该将early_stopping
设置为True
。
学习得不太快也不太慢
如前所述,损失函数(J)相对于权重(w)的梯度被用来更新网络的权重。更新是按照以下方程进行的,其中lr是学习率:
你可能会想,为什么需要学习率?为什么不直接通过设置lr = 1来使用梯度呢?在这一节中,我们将通过观察学习率对训练过程的影响来回答这个问题。
MLP 估算器中的另一个隐藏的宝藏是validation_scores_
。像loss_curve_
一样,这个参数也没有文档说明,并且其接口可能在未来的版本中发生变化。在MLPClassifier
中,validation_scores_
跟踪分类器在验证集上的准确度,而在MLPRegressor
中,它跟踪回归器的 R²得分。
我们将使用验证得分(validation_scores_
)来查看不同学习率的效果。由于这些得分只有在early_stopping
设置为True
时才会存储,而且我们不想提前停止,所以我们还将n_iter_no_change
设置为与max_iter
相同的值,以取消提前停止的效果。
默认的学习率是0.001
,并且在训练过程中默认保持不变。在这里,我们将选择一个更小的训练数据子集——1,000 个样本——并尝试从0.0001
到1
的不同学习率:
from sklearn.neural_network import MLPClassifier
learning_rate_init_options = [1, 0.1, 0.01, 0.001, 0.0001]
fig, axs = plt.subplots(1, len(learning_rate_init_options), figsize=(15, 5), sharex=True, sharey=True)
for i, learning_rate_init in enumerate(learning_rate_init_options):
print(f'{learning_rate_init} ', end='')
clf = MLPClassifier(
hidden_layer_sizes=(500, ),
learning_rate='constant',
learning_rate_init=learning_rate_init,
validation_fraction=0.2,
early_stopping=True,
n_iter_no_change=120,
max_iter=120,
solver='sgd',
batch_size=25,
verbose=0,
)
clf.fit(x_train[:1000,:], y_train_encoded[:1000])
pd.Series(clf.validation_scores_).plot(
title=f'learning_rate={learning_rate_init}',
kind='line',
color='k',
ax=axs[i]
)
fig.show()
以下图表比较了不同学习率下验证得分的进展。为了简洁起见,格式化坐标轴的代码被省略:
正如我们所看到的,当将学习率设置为1
时,网络无法学习,准确度停留在约 10%。这是因为较大的步伐更新权重导致梯度下降过度,错过了局部最小值。理想情况下,我们希望梯度下降能够在曲线上智慧地移动;它不应该急于求成,错过最优解。另一方面,我们可以看到,学习率非常低的0.0001
导致网络训练时间过长。显然,120
轮训练不够,因此需要更多的轮次。在这个例子中,学习率为0.01
看起来是一个不错的平衡。
学习率的概念通常在迭代方法中使用,以防止过度跳跃。它可能有不同的名称和不同的解释,但本质上它起到相同的作用。例如,在强化学习领域,贝尔曼方程中的折扣因子可能类似于这里的学习率。
选择合适的批量大小
在处理大量训练数据时,你不希望在计算梯度时一次性使用所有数据,尤其是当无法将这些数据完全加载到内存时。使用数据的小子集是我们可以配置的选项。在这里,我们将尝试不同的批量大小,同时保持其他设置不变。请记住,当batch_size
设置为1
时,模型会非常慢,因为它在每个训练实例后都更新一次权重:
from sklearn.neural_network import MLPClassifier
batch_sizes = [1, 10, 100, 1500]
fig, axs = plt.subplots(1, len(batch_sizes), figsize=(15, 5), sharex=True, sharey=True)
for i, batch_size in enumerate(batch_sizes):
print(f'{batch_size} ', end='')
clf = MLPClassifier(
hidden_layer_sizes=(500, ),
learning_rate='constant',
learning_rate_init=0.001,
momentum=0,
max_iter=250,
early_stopping=True,
n_iter_no_change=250,
solver='sgd',
batch_size=batch_size,
verbose=0,
)
clf.fit(x_train[:1500,:], y_train_encoded[:1500])
pd.Series(clf.validation_scores_).plot(
title=f'batch_size={batch_size}',
color='k',
kind='line',
ax=axs[i]
)
fig.show()
这张图给我们提供了四种批量大小设置及其效果的可视化比较。为了简洁起见,部分格式化代码被省略:
你可以看到,为什么小批量梯度下降在实践中成为了常态,不仅是因为内存限制,还因为较小的批次帮助我们的模型在此处更好地学习。尽管小批次大小下验证得分的波动较大,最终的结果还是达到了预期。另一方面,将batch_size
设置为1
会减慢学习过程。
到目前为止,我们已经调整了多个超参数,并见证了它们对训练过程的影响。除了这些超参数,还有两个问题仍然需要回答:
-
多少训练样本足够?
-
多少轮训练足够?
检查是否需要更多的训练样本
我们希望比较当使用整个训练样本(100%)时,使用 75%、50%、25%、10%和 5%的效果。learning_curve
函数在这种比较中很有用。它使用交叉验证来计算不同样本量下的平均训练和测试分数。在这里,我们将定义不同的采样比例,并指定需要三折交叉验证:
from sklearn.model_selection import learning_curve
train_sizes = [1, 0.75, 0.5, 0.25, 0.1, 0.05]
train_sizes, train_scores, test_scores = learning_curve(
MLPClassifier(
hidden_layer_sizes=(100, 100),
solver='adam',
early_stopping=False
),
x_train, y_train_encoded,
train_sizes=train_sizes,
scoring="precision_macro",
cv=3,
verbose=2,
n_jobs=-1
)
完成后,我们可以使用以下代码绘制训练和测试分数随着样本量增加的进展:
df_learning_curve = pd.DataFrame(
{
'train_sizes': train_sizes,
'train_scores': train_scores.mean(axis=1),
'test_scores': test_scores.mean(axis=1)
}
).set_index('train_sizes')
df_learning_curve['train_scores'].plot(
title='Learning Curves', ls=':',
)
df_learning_curve['test_scores'].plot(
title='Learning Curves', ls='-',
)
结果图表显示了随着更多训练数据的增加,分类器准确度的提升。注意到训练分数保持不变,而测试分数才是我们真正关心的,它在一定数据量后似乎趋于饱和:
在本章早些时候,我们从原始的 70,000 张图片中抽取了 10,000 张样本。然后将其拆分为 8,000 张用于训练,2,000 张用于测试。从学习曲线图中我们可以看到,实际上可以选择一个更小的训练集。在 2,000 张样本之后,额外的样本并没有带来太大的价值。
通常,我们希望使用尽可能多的数据样本来训练我们的模型。然而,在调整模型超参数时,我们需要做出妥协,使用较小的样本来加速开发过程。一旦完成这些步骤,就建议在整个数据集上训练最终模型。
检查是否需要更多的训练轮次
这一次,我们将使用validation_curve
函数。它的工作原理类似于learning_curve
函数,但它比较的是不同的超参数设置,而不是不同的训练样本量。在这里,我们将看到使用不同max_iter
值的效果:
from sklearn.model_selection import validation_curve
max_iter_range = [5, 10, 25, 50, 75, 100, 150]
train_scores, test_scores = validation_curve(
MLPClassifier(
hidden_layer_sizes=(100, 100),
solver='adam',
early_stopping=False
),
x_train, y_train_encoded,
param_name="max_iter", param_range=max_iter_range,
scoring="precision_macro",
cv=3,
verbose=2,
n_jobs=-1
)
通过训练和测试分数,我们可以像在上一节中一样绘制它们,从而得到以下图表:
在这个示例中,我们可以看到,测试分数大约在25
轮后停止提高。训练分数在此之后继续提升,直到达到 100%,这是过拟合的表现。实际上,我们可能不需要这个图表,因为我们使用early_stopping
、tol
和n_iter_no_change
超参数来停止训练过程,一旦学习足够并且避免过拟合。
选择最佳的架构和超参数
到目前为止,我们还没有讨论网络架构。我们应该有多少层,每层应该有多少节点?我们也没有比较不同的激活函数。正如你所看到的,有许多超参数可以选择。在本书之前的部分,我们提到过一些工具,如GridSearchCV
和RandomizedSearchCV
,它们帮助你选择最佳超参数。这些仍然是很好的工具,但如果我们决定使用它们来调节每个参数的所有可能值,它们可能会太慢。如果我们在使用过多的训练样本或进行太多的训练轮次时,它们也可能变得过于缓慢。
我们在前面部分看到的工具应该能帮助我们通过排除一些超参数范围,在一个稍微小一点的“大堆”中找到我们的“针”。它们还将允许我们坚持使用更小的数据集并缩短训练时间。然后,我们可以有效地使用GridSearchCV
和RandomizedSearchCV
来微调我们的神经网络。
在可能的情况下,建议使用并行化。GridSearchCV
和**RandomizedSearchCV
允许我们利用机器上的不同处理器同时训练多个模型。我们可以通过n_jobs
设置来实现这一点。这意味着,通过使用处理器数量较多的机器,你可以显著加速超参数调优过程。至于数据量,考虑到我们将执行 k 折交叉验证,并且训练数据会被进一步划分,我们应该增加比前一部分估算的数据量更多的数据。现在,话不多说,让我们使用GridSearchCV
来调优我们的网络:
**```py
from sklearn.model_selection import GridSearchCV
param_grid = {
‘hidden_layer_sizes’: [(50,), (50, 50), (100, 50), (100, 100), (500, 100), (500, 100, 100)],
‘activation’: [‘logistic’, ‘tanh’, ‘relu’],
‘learning_rate_init’: [0.01, 0.001],
‘solver’: [‘sgd’, ‘adam’],
}
gs = GridSearchCV(
estimator=MLPClassifier(
max_iter=50,
batch_size=50,
early_stopping=True,
),
param_grid=param_grid,
cv=4,
verbose=2,
n_jobs=-1
)
gs.fit(x_train[:2500,:], y_train_encoded[:2500])
它在四个 CPU 上运行了 14 分钟,选择了以下超参数:
+ **激活函数**:`relu`
+ **隐藏层大小**:`(500, 100)`
+ **初始学习率**:`0.01`
+ **优化器**:`adam`
所选模型在测试集上达到了**85.6%**的**微 F 得分**。通过使用`precision_recall_fscore_support`函数,你可以更详细地看到哪些类别比其他类别更容易预测:
<https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/f4e18add-f143-458a-893f-b185ce43422a.png>
理想情况下,我们应该使用整个训练集重新训练,但我现在先跳过这一部分。最终,开发一个最佳的神经网络通常被看作是艺术与科学的结合。然而,了解你的超参数及其效果的衡量方式应该让这一过程变得简单明了。然后,像`GridSearchCV`和`RandomizedSearchCV`这样的工具可以帮助你自动化部分过程。自动化在很多情况下优于技巧。
在进入下一个话题之前,我想稍微离题一下,给你展示如何构建自己的激活函数。
## 添加你自己的激活函数
许多激活函数的一个常见问题是梯度消失问题。如果你观察 `logistic` 和 `tanh` 激活函数的曲线,你会发现对于高正值和负值,曲线几乎是水平的。这意味着在这些高值下,曲线的梯度几乎是常数。这会阻碍学习过程。`relu` 激活函数尝试解决这个问题,但它仅解决了正值部分的问题,未能处理负值部分。这促使研究人员不断提出不同的激活函数。在这里,我们将把**ReLU**激活函数与其修改版**Leaky ReLU**进行对比:
<https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/18e43923-6e2d-415a-b2a7-bc78953f2730.png>
正如你在**Leaky ReLU**的示例中看到的,负值部分的线条不再是常数,而是以一个小的速率递减。为了添加**Leaky ReLU**,我需要查找 scikit-learn 中 `relu` 函数的构建方式,并毫不犹豫地修改代码以满足我的需求。基本上有两种方法可以构建。第一种方法用于前向传播路径,并仅将激活函数应用于其输入;第二种方法则将激活函数的导数应用于计算得到的误差。以下是我稍作修改以便简洁的 `relu` 的两个现有方法:
```py
def relu(X):
return np.clip(X, 0, np.finfo(X.dtype).max)
definplace_relu_derivative(Z, delta):
delta[Z==0] =0
在第一种方法中,使用了 NumPy 的 clip()
方法将负值设置为 0
。由于 clip
方法需要设置上下界,因此代码中的难懂部分是获取该数据类型的最大值,将其作为上界。第二种方法获取激活函数的输出(Z
)以及计算得到的误差(delta
)。它应该将误差乘以激活输出的梯度。然而,对于这种特定的激活函数,正值的梯度为 1
,负值的梯度为 0
。因此,对于负值,误差被设置为 0
,即当 relu
返回 0
时,误差就被设置为 0
。
leaky_relu
保持正值不变,并将负值乘以一个小的值 0.01
。现在,我们只需使用这些信息来构建新的方法:
leaky_relu_slope = 0.01
def leaky_relu(X):
X_min = leaky_relu_slope * np.array(X)
return np.clip(X, X_min, np.finfo(X.dtype).max)
def inplace_leaky_relu_derivative(Z, delta):
delta[Z < 0] = leaky_relu_slope * delta[Z < 0]
回顾一下,leaky_relu
在正值时的斜率为 1
,而在负值时的斜率为 leaky_relu_slope
常量。因此,我们将 Z
为负值的部分的增量乘以 leaky_relu_slope
。现在,在使用我们新的方法之前,我们需要将它们注入到 scikit-learn 的代码库中,具体如下:
from sklearn.neural_network._base import ACTIVATIONS, DERIVATIVES
ACTIVATIONS['leaky_relu'] = leaky_relu
DERIVATIVES['leaky_relu'] = inplace_leaky_relu_derivative
然后,你可以像最初就有 MLPClassifier
一样直接使用它:
clf = MLPClassifier(activation='leaky_relu')
像这样黑客攻击库迫使我们去阅读其源代码,并更好地理解它。它也展示了开源的价值,让你不再受限于现有的代码。接下来的部分,我们将继续进行黑客攻击,构建我们自己的卷积层。
解开卷积的复杂性
“深入观察大自然,你将更好地理解一切”
– 阿尔伯特·爱因斯坦
关于使用神经网络进行图像分类的章节,不能不提到卷积神经网络(CNN)。尽管 scikit-learn 并未实现卷积层,但我们仍然可以理解这一概念,并了解它是如何工作的。
让我们从以下的5 x 5 图像开始,看看如何将卷积层应用于它:
x_example = array(
[[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 1, 1, 0],
[0, 0, 1, 1, 0],
[0, 0, 0, 0, 0]]
)
在自然语言处理领域,词语通常作为字符与整个句子之间的中介来进行特征提取。在这张图中,也许较小的块比单独的像素更适合作为信息单元。本节的目标是寻找表示这些小的2 x 2、3 x 3 或 N x N 块的方法。我们可以从平均值作为总结开始。我们基本上可以通过将每个像素乘以 1,然后将总和除以 9,来计算每个3 x 3 块的平均值;这个块中有 9 个像素。对于边缘上的像素,因为它们在所有方向上没有邻居,我们可以假装在图像周围有一个额外的 1 像素边框,所有像素都设置为 0。通过这样做,我们得到另一个5 x 5 的数组。
这种操作被称为卷积,而SciPy提供了一种实现卷积的方法。3 x 3 的全 1 矩阵也被称为卷积核或权重。在这里,我们指定全 1 的卷积核,并在后面进行 9 的除法。我们还指定需要一个全零的边框,通过将mode
设置为constant
,cval
设置为0
,正如您在以下代码中所看到的那样:
from scipy import ndimage
kernel = [[1,1,1],[1,1,1],[1,1,1]]
x_example_convolve = ndimage.convolve(x_example, kernel, mode='constant', cval=0)
x_example_convolve = x_example_convolve / 9
这是原始图像与卷积输出之间的对比:
计算平均值给我们带来了模糊的原始图像版本,所以下次当你需要模糊图像时,你知道该怎么做。将每个像素乘以某个权重并计算这些乘积的总和听起来像是一个线性模型。此外,我们可以将平均值看作是线性模型,其中所有权重都设置为 https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/f1e2a388-dfb2-4f7a-ba68-5bd41656920e.png。因此,你可以说我们正在为图像的每个块构建迷你线性模型。记住这个类比,但现在,我们必须手动设置模型的权重。
虽然每个块使用的线性模型与其他块完全相同,但没有什么可以阻止每个块内的像素被不同的权重相乘。事实上,不同的卷积核和不同的权重会产生不同的效果。在下一节中,我们将看到不同卷积核对我们Fashion-MNIST数据集的影响。
通过卷积提取特征
与其逐个处理图像,我们可以调整代码一次性对多张图像进行卷积。我们的 Fashion-MNIST 数据集中的图像是平铺的,因此我们需要将它们重新调整为28 x 28 像素的格式。然后,我们使用给定的卷积核进行卷积,最后,确保所有像素值都在0
和1
之间,使用我们最喜爱的MinMaxScaler
参数:
from scipy import ndimage
from sklearn.preprocessing import MinMaxScaler
def convolve(x, kernel=[[1,1,1],[1,1,1],[1,1,1]]):
w = int(np.sqrt(x.shape[1]))
x = ndimage.convolve(
x.reshape((x.shape[0], w, w)), [kernel],
mode='constant', cval=0.0
)
x = x.reshape(x.shape[0], x.shape[1]*x.shape[2])
return MinMaxScaler().fit_transform(x)
接下来,我们可以将其作为我们的训练和测试数据,如下所示:
sharpen_kernel = [[0,-1,0], [-1,5,-1], [0,-1,0]]
x_train_conv = convolve(x_train, sharpen_kernel)
x_test_conv = convolve(x_test, sharpen_kernel)
这里有几个卷积核:第一个用于锐化图像,接着是一个用于强调垂直边缘的卷积核,而最后一个则强调水平边缘:
-
锐化:
[[0,-1,0], [-1,5,-1], [0,-1,0]]
-
垂直边缘:
[[-1,0,1], [-2,0,2], [-1,0,1]]
-
水平边缘:
[[-1,-2,-1], [0,0,0], [1,2,1]]
将这些卷积核传给我们刚刚创建的卷积函数将得到以下效果:
你可以在互联网上找到更多的卷积核,或者你也可以尝试自己定义,看看它们的效果。卷积核也是直观的;锐化卷积核显然更重视中央像素而非其周围的像素。
每个不同的卷积变换从我们的图像中捕获了特定的信息。因此,我们可以将它们看作一个特征工程层,从中提取特征供分类器使用。然而,随着我们将更多的卷积变换添加到数据中,数据的大小会不断增长。在下一部分,我们将讨论如何处理这个问题。
通过最大池化来减少数据的维度
理想情况下,我们希望将前几个卷积变换的输出输入到我们的神经网络中。然而,如果我们的图像由 784 个像素构成,那么仅仅连接三个卷积函数的输出将会产生 2,352 个特征,784 x 3。这将会减慢我们的训练过程,而且正如我们在本书前面所学到的,特征越多并不总是越好。
要将图像缩小为原来大小的四分之一——即宽度和高度各缩小一半——你可以将图像划分为多个2 x 2的补丁,然后在每个补丁中取最大值来表示整个补丁。这正是最大池化的作用。为了实现它,我们需要在计算机终端通过pip
安装另一个名为scikit-image
的库:
pipinstallscikit-image
然后,我们可以创建我们的最大池化函数,如下所示:
from skimage.measure import block_reduce
from sklearn.preprocessing import MinMaxScaler
def maxpool(x, size=(2,2)):
w = int(np.sqrt(x.shape[1]))
x = np.array([block_reduce(img.reshape((w, w)), block_size=(size[0], size[1]), func=np.max) for img in x])
x = x.reshape(x.shape[0], x.shape[1]*x.shape[2])
return MinMaxScaler().fit_transform(x)
然后,我们可以将它应用到其中一个卷积的输出上,具体如下:
x_train_maxpool = maxpool(x_train_conv, size=(5,5))
x_test_maxpool = maxpool(x_test_conv, size=(5,5))
在5 x 5的补丁上应用最大池化将把数据的大小从28 x 28缩小到6 x 6,即原始大小的不到 5%。
将一切整合在一起
FeatureUnion
管道可以在 scikit-learn 中将多个转换器的输出组合起来。换句话说,如果 scikit-learn 有可以对图像进行卷积并对这些卷积输出进行最大池化的转换器,那么你就可以将多个转换器的输出结合起来,每个转换器都使用不同的卷积核。幸运的是,我们可以自己构建这个转换器,并通过FeatureUnion
将它们的输出结合起来。我们只需要让它们提供 fit、transform 和 fit_transform 方法,如下所示:
class ConvolutionTransformer:
def __init__(self, kernel=[], max_pool=False, max_pool_size=(2,2)):
self.kernel = kernel
self.max_pool = max_pool
self.max_pool_size = max_pool_size
def fit(self, x):
return x
def transform(self, x, y=None):
x = convolve(x, self.kernel)
if self.max_pool:
x = maxpool(x, self.max_pool_size)
return x
def fit_transform(self, x, y=None):
x = self.fit(x)
return self.transform(x)
你可以在初始化步骤中指定使用的卷积核。你还可以通过将max_pool
设置为False
来跳过最大池化部分。这里,我们定义了三个卷积核,并在对每个4 x 4图像块进行池化时,组合它们的输出:
kernels = [
('Sharpen', [[0,-1,0], [-1,5,-1], [0,-1,0]]),
('V-Edge', [[-1,0,1], [-2,0,2], [-1,0,1]]),
('H-Edge', [[-1,-2,-1], [0,0,0], [1,2,1]]),
]
from sklearn.pipeline import FeatureUnion
funion = FeatureUnion(
[
(kernel[0], ConvolutionTransformer(kernel=kernel[1], max_pool=True, max_pool_size=(4,4)))
for kernel in kernels
]
)
x_train_convs = funion.fit_transform(x_train)
x_test_convs = funion.fit_transform(x_test)
然后,我们可以将FeatureUnion
管道的输出传递到我们的神经网络中,如下所示:
from sklearn.neural_network import MLPClassifier
mlp = MLPClassifier(
hidden_layer_sizes=(500, 300),
activation='relu',
learning_rate_init=0.01,
solver='adam',
max_iter=80,
batch_size=50,
early_stopping=True,
)
mlp.fit(x_train_convs, y_train)
y_test_predict = mlp.predict(x_test_convs)
该网络达到了微 F 值为79%。你可以尝试添加更多的卷积核并调整网络的超参数,看看我们是否能比没有卷积层时获得更好的得分。
我们必须手动设置卷积层的核权重。然后,我们显示它们的输出,以查看它们是否直观合理,并希望它们在使用时能提高我们模型的表现。这听起来不像是一个真正的数据驱动方法。理想情况下,你希望权重能够从数据中学习。这正是实际的卷积神经网络(CNN)所做的。我建议你了解 TensorFlow 和 PyTorch,它们提供了 CNN 的实现。如果你能将它们的准确度与我们这里构建的模型进行比较,那将非常好。
MLP 回归器
除了MLPClassifier
,还有它的回归兄弟MLPRegressor
。这两者共享几乎相同的接口。它们之间的主要区别在于每个使用的损失函数和输出层的激活函数。回归器优化平方损失,最后一层由恒等函数激活。所有其他超参数相同,包括隐藏层的四种激活选项。
两个估算器都有一个partial_fit()
方法。你可以在估算器已经拟合后,获取额外的训练数据时,使用它来更新模型。在MLPRegressor
中,score()
计算的是回归器的R*²,而分类器的准确度则由MLPClassifier
计算。
****# 总结
我们现在已经对人工神经网络(ANNs)及其底层技术有了很好的理解。我推荐使用像 TensorFlow 和 PyTorch 这样的库来实现更复杂的架构,并且可以在 GPU 上扩展训练过程。不过,你已经有了很好的起步。这里讨论的大部分概念可以转移到任何其他库上。你将使用相似的激活函数和求解器,以及这里讨论的大部分其他超参数。scikit-learn 的实现仍然适用于原型开发以及我们想要超越线性模型的情况,且不需要太多隐藏层。
此外,像梯度下降这样的求解器在机器学习领域是如此普遍,理解它们的概念对于理解其他不是神经网络的算法也很有帮助。我们之前看到梯度下降如何用于训练线性回归器、逻辑回归器以及支持向量机。我们还将在下一章中使用它们与梯度提升算法。
无论你使用什么算法,像学习率以及如何估计所需训练数据量等概念都是非常有用的。得益于 scikit-learn 提供的有用工具,这些概念得以轻松应用。即使在我并非在构建机器学习解决方案时,我有时也会使用 scikit-learn 的工具。
如果人工神经网络(ANNs)和深度学习是媒体的鸦片,那么集成算法就是大多数从业者在解决任何商业问题或在 Kaggle 上争夺$10,000 奖金时的“面包和黄油”。
在下一章中,我们将学习不同的集成方法及其理论背景,然后亲自动手调优它们的超参数。****************
第八章:集成方法——当一个模型不足以应对时
在前面的三章中,我们看到神经网络如何直接或间接地帮助解决自然语言理解和图像处理问题。这是因为神经网络已被证明能够很好地处理同质数据;即,如果所有输入特征属于同一类——像素、单词、字符等。另一方面,当涉及到异质数据时,集成方法被认为能够发挥优势。它们非常适合处理异质数据——例如,一列包含用户的年龄,另一列包含他们的收入,第三列包含他们的居住城市。
你可以将集成估计器视为元估计器;它们由多个其他估计器的实例组成。它们组合底层估计器的方式决定了不同集成方法之间的差异——例如,袋装法与提升法。在本章中,我们将详细探讨这些方法,并理解它们的理论基础。我们还将学习如何诊断自己的模型,理解它们为何做出某些决策。
一如既往,我还希望借此机会在剖析每个单独的算法时,顺便阐明一些常见的机器学习概念。在本章中,我们将看到如何利用分类器的概率和回归范围来处理估计器的不确定性。
本章将讨论以下内容:
-
集成方法的动机
-
平均法/袋装集成方法
-
提升集成方法
-
回归范围
-
ROC 曲线
-
曲线下的面积
-
投票法与堆叠集成方法
回答为什么选择集成方法的问题?
集成方法背后的主要思想是将多个估计器结合起来,使它们的预测效果比单一估计器更好。然而,你不应该仅仅期待多个估计器的简单结合就能带来更好的结果。多个估计器的预测组合如果犯了完全相同的错误,其结果会与每个单独的估计器一样错误。因此,考虑如何减轻单个估计器所犯的错误是非常有帮助的。为此,我们需要回顾一下我们熟悉的偏差-方差二分法。很少有机器学习的老师比这对概念更能帮助我们了。
如果你还记得第二章《用树做决策》,当我们允许决策树尽可能生长时,它们往往会像手套一样拟合训练数据,但无法很好地推广到新的数据点。我们称之为过拟合,在线性模型和少量最近邻的情况下也看到了相同的行为。相反,严格限制树的生长,限制线性模型中的特征数量,或者要求太多邻居投票,都会导致模型偏向并且拟合不足。因此,我们必须在偏差-方差和拟合不足-过拟合的对立之间找到最佳平衡。
在接下来的章节中,我们将采取一种不同的方法。我们将把偏差-方差的对立看作一个连续的尺度,从这个尺度的一端开始,并利用集成的概念向另一端推进。在下一节中,我们将从高方差估计器开始,通过平均它们的结果来减少它们的方差。随后,我们将从另一端开始,利用提升的概念来减少估计器的偏差。
通过平均结合多个估计器
“为了从多个证据源中提取最有用的信息,你应该始终尝试使这些来源彼此独立。”
–丹尼尔·卡尼曼
如果单棵完全生长的决策树发生过拟合,并且在最近邻算法中,增加投票者数量产生相反的效果,那么为什么不将这两个概念结合起来呢?与其拥有一棵树,不如拥有一片森林,将其中每棵树的预测结果结合起来。然而,我们并不希望森林中的所有树都是相同的;我们希望它们尽可能多样化。袋装和随机森林元估计器就是最常见的例子。为了实现多样性,它们确保每个单独的估计器都在训练数据的随机子集上进行训练——因此在随机森林中有了随机这个前缀。每次抽取随机样本时,可以进行有放回抽样(自助法)或无放回抽样(粘贴法)。术语袋装代表自助法聚合,因为估计器在抽样时是有放回的。此外,为了实现更多的多样性,集成方法还可以确保每棵树看到的训练特征是随机选择的子集。
这两种集成方法默认使用决策树估计器,但袋装集成方法可以重新配置为使用其他任何估计器。理想情况下,我们希望使用高方差估计器。各个估计器做出的决策通过投票或平均来结合。
提升多个有偏估计器
“如果我看得比别人更远,那是因为我站在巨人的肩膀上。”
–艾萨克·牛顿
与完全生长的树相比,浅层树往往会产生偏差。提升一个偏差估计器通常通过AdaBoost或梯度提升来实现。AdaBoost 元估计器从一个弱估计器或偏差估计器开始,然后每一个后续估计器都从前一个估计器的错误中学习。我们在第二章《使用决策树做决策》中看到过,我们可以给每个训练样本分配不同的权重,从而让估计器对某些样本给予更多关注。 在AdaBoost中,前一个估计器所做的错误预测会赋予更多的权重,以便后续的估计器能够更加关注这些错误。
梯度提升元估计器采用了稍微不同的方法。它从一个偏差估计器开始,计算其损失函数,然后构建每个后续估计器以最小化前一个估计器的损失函数。正如我们之前所看到的,梯度下降法在迭代最小化损失函数时非常有用,这也是梯度提升算法名称中“梯度”前缀的由来。
由于这两种集成方法都是迭代性质的,它们都有一个学习率来控制学习速度,并确保在收敛时不会错过局部最小值。像自助法算法一样,AdaBoost 并不局限于使用决策树作为基本估计器。
现在我们对不同的集成方法有了一个大致了解,接下来可以使用真实的数据来演示它们在实际中的应用。这里描述的每个集成方法都可以用于分类和回归。分类器和回归器的超参数对于每个集成都几乎是相同的。因此,我将选择一个回归问题来演示每个算法,并简要展示随机森林和梯度提升算法的分类能力,因为它们是最常用的集成方法。
在下一节中,我们将下载由加利福尼亚大学欧文分校(UCI)准备的数据集。它包含了 201 个不同汽车的样本以及它们的价格。我们将在后面的章节中使用该数据集通过回归预测汽车价格。
下载 UCI 汽车数据集
汽车数据集由 Jeffrey C. Schlimmer 创建并发布在 UCI 的机器学习库中。它包含了 201 辆汽车的信息以及它们的价格。特征名称缺失,不过我可以从数据集的描述中找到它们(archive.ics.uci.edu/ml/machine-learning-databases/autos/imports-85.names
)。因此,我们可以先查看 URL 和特征名称,如下所示:
url = 'http://archive.ics.uci.edu/ml/machine-learning-databases/autos/imports-85.data'
header = [
'symboling',
'normalized-losses',
'make',
# ... some list items are omitted for brevity
'highway-mpg',
'price',
]
然后,我们使用以下代码来下载我们的数据。
df = pd.read_csv(url, names=header, na_values='?')
在数据集描述中提到,缺失值被替换为问号。为了让代码更符合 Python 风格,我们将na_values
设置为'?'
,用 NumPy 的不是数字(NaN)替换这些问号。
接下来,我们可以进行探索性数据分析(EDA),检查缺失值的百分比,并查看如何处理它们。
处理缺失值
现在,我们可以检查哪些列缺失值最多:
cols_with_missing = df.isnull().sum()
cols_with_missing[
cols_with_missing > 0
]
这为我们提供了以下列表:
normalized-losses 41
num-of-doors 2
bore 4
stroke 4
horsepower 2
peak-rpm 2
price 4
由于价格是我们的目标值,我们可以忽略那些价格未知的四条记录:
df = df[~df['price'].isnull()]
对于剩余的特征,我认为我们可以删除normalized-losses
列,因为其中有 41 个值是缺失的。稍后,我们将使用数据插补技术处理其他缺失值较少的列。你可以使用以下代码删除normalized-losses
列:
df.drop(labels=['normalized-losses'], axis=1, inplace=True)
此时,我们已经有了一个包含所有必要特征及其名称的数据框。接下来,我们想将数据拆分为训练集和测试集,然后准备特征。不同的特征类型需要不同的准备工作。你可能需要分别缩放数值特征并编码类别特征。因此,能够区分数值型特征和类别型特征是一个很好的实践。
区分数值特征和类别特征
在这里,我们将创建一个字典,分别列出数值型和类别型特征。我们还将这两者合并为一个列表,并提供目标列的名称,如以下代码所示:
features = {
'categorical': [
'make', 'fuel-type', 'aspiration', 'num-of-doors',
'body-style', 'drive-wheels', 'engine-location',
'engine-type', 'num-of-cylinders', 'fuel-system',
],
'numerical': [
'symboling', 'wheel-base', 'length', 'width', 'height',
'curb-weight', 'engine-size', 'bore', 'stroke',
'compression-ratio', 'horsepower', 'peak-rpm',
'city-mpg', 'highway-mpg',
],
}
features['all'] = features['categorical'] + features['numerical']
target = 'price'
通过这样做,你可以以不同的方式处理列。此外,为了保持理智并避免将来打印过多的零,我将价格重新缩放为千元,如下所示:
df[target] = df[target].astype(np.float64) / 1000
你也可以单独显示某些特征。在这里,我们打印一个随机样本,仅显示类别特征:
df[features['categorical']].sample(n=3, random_state=42)
这里是生成的行。我将random_state
设置为42
,确保我们得到相同的随机行:
所有其他转换,如缩放、插补和编码,都应该在拆分数据集为训练集和测试集之后进行。这样,我们可以确保没有信息从测试集泄漏到训练样本中。
将数据拆分为训练集和测试集
在这里,我们保留 25%的数据用于测试,其余的用于训练:
from sklearn.model_selection import train_test_split
df_train, df_test = train_test_split(df, test_size=0.25, random_state=22)
然后,我们可以使用前面部分的信息创建我们的x
和y
值:
x_train = df_train[features['all']]
x_test = df_test[features['all']]
y_train = df_train[target]
y_test = df_test[target]
和往常一样,对于回归任务,了解目标值的分布是很有用的:
y_train.plot(
title="Distribution of Car Prices (in 1000's)",
kind='hist',
)
直方图通常是理解分布的一个好选择,如下图所示:
我们可能会稍后回到这个分布,来将回归模型的平均误差放到合理的范围内。此外,你还可以使用这个范围进行合理性检查。例如,如果你知道所有看到的价格都在 5,000 到 45,000 之间,你可能会决定在将模型投入生产时,如果模型返回的价格远离这个范围,就触发警报。
填充缺失值并编码类别特征
在启用我们的集成方法之前,我们需要确保数据中没有空值。我们将使用来自第四章《准备数据》的SimpleImputer
函数,用每列中最常见的值来替换缺失值:
from sklearn.impute import SimpleImputer
imp = SimpleImputer(missing_values=np.nan, strategy='most_frequent')
x_train = imp.fit_transform(x_train)
x_test = imp.transform(x_test)
你可能已经看到我多次抱怨 scikit-learn 的转换器,它们不尊重列名,并且坚持将输入数据框转换为 NumPy 数组。为了不再抱怨,我通过使用以下ColumnNamesKeeper
类来解决我的痛点。每当我将它包装在转换器周围时,它会确保所有的数据框都保持不变:
class ColumnNamesKeeper:
def __init__(self, transformer):
self._columns = None
self.transformer = transformer
def fit(self, x, y=None):
self._columns = x.columns
self.transformer.fit(x)
def transform(self, x, y=None):
x = self.transformer.transform(x)
return pd.DataFrame(x, columns=self._columns)
def fit_transform(self, x, y=None):
self.fit(x, y)
return self.transform(x)
如你所见,它主要在调用fit
方法时保存列名。然后,我们可以使用保存的列名在变换步骤后重新创建数据框。
ColumnNamesKeeper
的代码可以通过继承sklearn.base.BaseEstimator
和sklearn.base.TransformerMixin
来进一步简化。如果你愿意编写更符合 scikit-learn 风格的转换器,可以查看该库内置转换器的源代码。
现在,我可以再次调用SimpleImputer
,同时保持x_train
和x_test
作为数据框:
from sklearn.impute import SimpleImputer
imp = ColumnNamesKeeper(
SimpleImputer(missing_values=np.nan, strategy='most_frequent')
)
x_train = imp.fit_transform(x_train)
x_test = imp.transform(x_test)
我们在第四章《准备数据》中学到,OrdinalEncoder
推荐用于基于树的算法,此外还适用于任何其他非线性算法。category_encoders
库不会改变列名,因此我们这次可以直接使用OrdinalEncoder
,无需使用ColumnNamesKeeper
。在以下代码片段中,我们还指定了要编码的列(类别列)和保持不变的列(其余列):
**```py
from category_encoders.ordinal import OrdinalEncoder
enc = OrdinalEncoder(
cols=features[‘categorical’],
handle_unknown=‘value’
)
x_train = enc.fit_transform(x_train)
x_test = enc.transform(x_test)
除了`OrdinalEncoder`,你还可以测试第四章《准备数据》中提到的目标编码器*。它们同样适用于本章中解释的算法。在接下来的部分,我们将使用随机森林算法来处理我们刚准备好的数据。
# 使用随机森林进行回归
随机森林算法将是我们首先要处理的集成方法。它是一个容易理解的算法,具有直接的超参数设置。尽管如此,我们通常的做法是先使用默认值训练算法,如下所示,然后再解释其超参数:
```py
from sklearn.ensemble import RandomForestRegressor
rgr = RandomForestRegressor(n_jobs=-1)
rgr.fit(x_train, y_train)
y_test_pred = rgr.predict(x_test)
由于每棵树相互独立,我将n_jobs
设置为-1
,以利用多个处理器并行训练树木。一旦它们训练完成并获得预测结果,我们可以打印出以下的准确度指标:
from sklearn.metrics import (
mean_squared_error, mean_absolute_error, median_absolute_error, r2_score
)
print(
'R2: {:.2f}, MSE: {:.2f}, RMSE: {:.2f}, MAE {:.2f}'.format(
r2_score(y_test, y_test_pred),
mean_squared_error(y_test, y_test_pred),
np.sqrt(mean_squared_error(y_test, y_test_pred)),
mean_absolute_error(y_test, y_test_pred),
)
)
这将打印出以下分数:
# R2: 0.90, MSE: 4.54, RMSE: 2.13, MAE 1.35
平均汽车价格为 13,400。因此,平均绝对误差(MAE)为1.35
是合理的。至于均方误差(MSE),将其平方根作为度量单位比较更为合适,以与 MAE 保持一致。简而言之,鉴于高 R²分数和较低的误差,算法在默认值下表现良好。此外,你可以绘制误差图,进一步了解模型的表现:
df_pred = pd.DataFrame(
{
'actuals': y_test,
'predictions': y_test_pred,
}
)
df_pred['error'] = np.abs(y_test - y_test_pred)
fig, axs = plt.subplots(1, 2, figsize=(16, 5), sharey=False)
df_pred.plot(
title='Actuals vs Predictions',
kind='scatter',
x='actuals',
y='predictions',
ax=axs[0],
)
df_pred['error'].plot(
title='Distribution of Error',
kind='hist',
ax=axs[1],
)
fig.show()
为了保持代码简洁,我省略了一些格式化行。最后,我们得到以下图表:
通过绘制预测值与实际值的对比图,我们可以确保模型不会系统性地高估或低估。这一点通过左侧散点的 45 度斜率得到了体现。散点斜率较低会系统性地反映低估。如果散点在一条直线上的分布,意味着模型没有遗漏任何非线性因素。右侧的直方图显示大多数误差低于 2,000。了解未来可以预期的平均误差和最大误差是很有帮助的。
检查树木数量的影响
默认情况下,每棵树都会在训练数据的随机样本上进行训练。这是通过将bootstrap
超参数设置为True
实现的。在自助采样中,某些样本可能会在训练中被使用多次,而其他样本可能根本没有被使用。
当max_samples
设置为None
时,每棵树都会在一个随机样本上训练,样本的大小等于整个训练数据的大小。你可以将max_samples
设置为小于 1 的比例,这样每棵树就会在一个更小的随机子样本上训练。同样,我们可以将max_features
设置为小于 1 的比例,以确保每棵树使用可用特征的随机子集。这些参数有助于让每棵树具有自己的“个性”,并确保森林的多样性。更正式地说,这些参数增加了每棵树的方差。因此,建议尽可能增加树木的数量,以减少我们刚刚引入的方差。
在这里,我们比较了三片森林,每片森林中树木的数量不同:
mae = []
n_estimators_options = [5, 500, 5000]
for n_estimators in n_estimators_options:
rgr = RandomForestRegressor(
n_estimators=n_estimators,
bootstrap=True,
max_features=0.75,
max_samples=0.75,
n_jobs=-1,
)
rgr.fit(x_train, y_train)
y_test_pred = rgr.predict(x_test)
mae.append(mean_absolute_error(y_test, y_test_pred))
然后,我们可以绘制每个森林的 MAE,以查看增加树木数量的优点:
显然,我们刚刚遇到了需要调优的新的超参数集bootstrap
、max_features
和max_samples
。因此,进行交叉验证来调整这些超参数是有意义的。
理解每个训练特征的影响
一旦随机森林训练完成,我们可以列出训练特征及其重要性。通常情况下,我们通过使用列名和feature_importances_
属性将结果放入数据框中,如下所示:
df_feature_importances = pd.DataFrame(
{
'Feature': x_train.columns,
'Importance': rgr.feature_importances_,
}
).sort_values(
'Importance', ascending=False
)
这是生成的数据框:
与线性模型不同,这里的所有值都是正的。这是因为这些值仅显示每个特征的重要性,无论它们与目标的正负相关性如何。这对于决策树以及基于树的集成模型来说是常见的。因此,我们可以使用部分依赖图(PDPs)来展示目标与不同特征之间的关系。在这里,我们仅针对按重要性排名前六的特征绘制图表:
from sklearn.inspection import plot_partial_dependence
fig, ax = plt.subplots(1, 1, figsize=(15, 7), sharey=False)
top_features = df_feature_importances['Feature'].head(6)
plot_partial_dependence(
rgr, x_train,
features=top_features,
n_cols=3,
n_jobs=-1,
line_kw={'color': 'k'},
ax=ax
)
ax.set_title('Partial Dependence')
fig.show()
结果图表更易于阅读,特别是当目标与特征之间的关系是非线性时:
现在我们可以看出,具有更大引擎、更多马力和每加仑油耗更少的汽车往往更昂贵。
PDP 不仅对集成方法有用,对于任何其他复杂的非线性模型也很有用。尽管神经网络对每一层都有系数,但 PDP 对于理解整个网络至关重要。此外,您还可以通过将特征列表作为元组列表传递,每个元组中有一对特征,来理解不同特征对之间的相互作用。
使用随机森林进行分类
为了演示随机森林分类器,我们将使用一个合成数据集。我们首先使用内置的make_hastie_10_2
类创建数据集:
from sklearn.datasets import make_hastie_10_2
x, y = make_hastie_10_2(n_samples=6000, random_state=42)
上述代码片段创建了一个随机数据集。我将random_state
设置为一个固定的数,以确保我们获得相同的随机数据。现在,我们可以将生成的数据分为训练集和测试集:
from sklearn.model_selection import train_test_split
x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.25, random_state=42)
接下来,为了评估分类器,我们将在下一节介绍一个名为接收者操作特征曲线(ROC)的新概念。
ROC 曲线
“概率是建立在部分知识上的期望。对事件发生影响的所有情况的完全了解会把期望转变为确定性,并且不会留下概率理论的需求或空间。”
– 乔治·布尔(布尔数据类型以他命名)
在分类问题中,分类器会为每个样本分配一个概率值,以反映该样本属于某一类别的可能性。我们通过分类器的predict_proba()
方法获得这些概率值。predict()
方法通常是predict_proba()
方法的封装。在二分类问题中,如果样本属于某个类别的概率超过 50%,则将其分配到该类别。实际上,我们可能并不总是希望遵循这个 50%的阈值,尤其是因为不同的阈值通常会改变真正阳性率(TPRs)和假阳性率(FPRs)在每个类别中的表现。因此,你可以选择不同的阈值来优化所需的 TPR。
最好的方法来决定哪个阈值适合你的需求是使用 ROC 曲线。这有助于我们看到每个阈值下的 TPR 和 FPR。为了创建这个曲线,我们将使用我们刚创建的合成数据集来训练我们的随机森林分类器,但这次我们会获取分类器的概率值:
from sklearn.ensemble import RandomForestClassifier
clf = RandomForestClassifier(
n_estimators=100,
oob_score=True,
n_jobs=-1,
)
clf.fit(x_train, y_train)
y_pred_proba = clf.predict_proba(x_test)[:,1]
然后,我们可以按以下方式计算每个阈值的 TPR 和 FPR:
from sklearn.metrics import roc_curve
fpr, tpr, thr = roc_curve(y_test, y_pred_proba)
让我们停下来稍作解释,看看 TPR 和 FPR 是什么意思:
-
TPR,也叫做召回率或敏感度,计算方法是真正阳性(TP)案例的数量除以所有正类案例的数量;即,!,其中FN是被错误分类为负类的正类案例(假阴性)。
-
真正阴性率(TNR),也叫做特异度,计算方法是真正阴性(TN)案例的数量除以所有负类案例的数量;即,!,其中FP是被错误分类为正类的负类案例(假阳性)。
现在,我们可以将我们计算出的 TPR 和 FPR 放入以下表格中:
比表格更好的是,我们可以使用以下代码将其绘制成图表:
pd.DataFrame(
{'FPR': fpr, 'TPR': tpr}
).set_index('FPR')['TPR'].plot(
title=f'Receiver Operating Characteristic (ROC)',
label='Random Forest Classifier',
kind='line',
)
为了简洁起见,我省略了图表的样式代码。我还添加了一个 45°的线条和曲线下面积(AUC),稍后我会解释这个概念:
一个随机将每个样本分配到某个类别的分类器,其 ROC 曲线将像虚线的 45 度线一样。任何在此基础上的改进都会使曲线更向上凸起。显然,随机森林的 ROC 曲线优于随机猜测。一个最佳分类器将触及图表的左上角。因此,AUC 可以用来反映分类器的好坏。0.5
以上的区域比随机猜测好,1.0
是最佳值。我们通常期待的 AUC 值在0.5
到1.0
之间。在这里,我们得到了0.94
的 AUC。AUC 可以使用以下代码来计算:
from sklearn.metrics import auc
auc_values = auc(fpr, tpr)
我们还可以使用 ROC 和 AUC 来比较两个分类器。在这里,我训练了bootstrap
超参数设置为True
的随机森林分类器,并将其与bootstrap
设置为False
时的相同分类器进行了比较:
难怪bootstrap
超参数默认设置为True
——它能提供更好的结果。现在,你已经看到如何使用随机森林算法来解决分类和回归问题。在下一节中,我们将解释一个类似的集成方法:包外集成方法。
使用包外回归器
我们将回到汽车数据集,因为这次我们将使用包外回归器。包外元估计器与随机森林非常相似。它由多个估计器构成,每个估计器都在数据的随机子集上进行训练,使用自助采样方法。这里的关键区别是,虽然默认情况下使用决策树作为基本估计器,但也可以使用任何其他估计器。出于好奇,这次我们将K-最近邻(KNN)回归器作为我们的基本估计器。然而,我们需要准备数据,以适应新回归器的需求。
准备数值和类别特征的混合
在使用基于距离的算法(如 KNN)时,建议将所有特征放在相同的尺度上。否则,具有更大量级的特征对距离度量的影响将会掩盖其他特征的影响。由于我们这里有数值和类别特征的混合,因此我们需要创建两个并行的管道,分别准备每个特征集。
这是我们管道的顶层视图:
在这里,我们首先构建管道中的四个变换器:Imputer
、Scaler
**、**和OneHotEncoder
。我们还将它们包装在ColumnNamesKeeper
中,这是我们在本章前面创建的:
from sklearn.impute import SimpleImputer
from category_encoders.one_hot import OneHotEncoder
from sklearn.preprocessing import MinMaxScaler
from sklearn.pipeline import Pipeline
numerical_mputer = ColumnNamesKeeper(
SimpleImputer(
missing_values=np.nan,
strategy='median'
)
)
categorical_mputer = ColumnNamesKeeper(
SimpleImputer(
missing_values=np.nan,
strategy='most_frequent'
)
)
minmax_scaler = ColumnNamesKeeper(
MinMaxScaler()
)
onehot_encoder = OneHotEncoder(
cols=features['categorical'],
handle_unknown='value'
)
然后,我们将它们放入两个并行的管道中:
numerical_pipeline = Pipeline(
[
('numerical_mputer', numerical_mputer),
('minmax_scaler', minmax_scaler)
]
)
categorical_pipeline = Pipeline(
[
('categorical_mputer', categorical_mputer),
('onehot_encoder', onehot_encoder)
]
)
最后,我们将训练集和测试集的管道输出连接起来:
x_train_knn = pd.concat(
[
numerical_pipeline.fit_transform(df_train[features['numerical']]),
categorical_pipeline.fit_transform(df_train[features['categorical']]),
],
axis=1
)
x_test_knn = pd.concat(
[
numerical_pipeline.transform(df_test[features['numerical']]),
categorical_pipeline.transform(df_test[features['categorical']]),
],
axis=1
)
此时,我们准备好构建我们的包外 KNN。
使用包外元估计器结合 KNN 估计器
BaggingRegressor
有一个 base_estimator
超参数,你可以在其中设置你想要使用的估算器。这里,KNeighborsRegressor
与一个单一邻居一起使用。由于我们是通过聚合多个估算器来减少它们的方差,因此一开始就使用高方差的估算器是合理的,因此这里邻居的数量较少:
from sklearn.ensemble import BaggingRegressor
from sklearn.neighbors import KNeighborsRegressor
rgr = BaggingRegressor(
base_estimator=KNeighborsRegressor(
n_neighbors=1
),
n_estimators=400,
)
rgr.fit(x_train_knn, df_train[target])
y_test_pred = rgr.predict(x_test_knn)
这个新设置给我们带来了 1.8
的 MAE。我们可以在这里停下来,或者我们可以决定通过调整一系列超参数来改进集成的性能。
首先,我们可以尝试不同的估算器,而不是 KNN,每个估算器都有自己的超参数。然后,Bagging 集成也有自己的超参数。我们可以通过 n_estimators
来更改估算器的数量。然后,我们可以通过 max_samples
来决定是否对每个估算器使用整个训练集或其随机子集。同样,我们也可以通过 max_features
来选择每个估算器使用的列的随机子集。是否对行和列使用自助抽样,可以通过 bootstrap
和 bootstrap_features
超参数分别来决定。
最后,由于每个估算器都是单独训练的,我们可以使用具有大量 CPU 的机器,并通过将 n_jobs
设置为 -1
来并行化训练过程。
现在我们已经体验了两种平均集成方法,是时候检查它们的提升法对应方法了。我们将从梯度提升集成开始,然后转到 AdaBoost 集成。
使用梯度提升法预测汽车价格
如果我被困在一个荒岛上,只能带一个算法,我一定会选择梯度提升集成!它已经在许多分类和回归问题中证明了非常有效。我们将使用与之前章节相同的汽车数据。该集成的分类器和回归器版本共享完全相同的超参数,唯一不同的是它们使用的损失函数。这意味着我们在这里学到的所有知识都将在我们决定使用梯度提升集成进行分类时派上用场。
与我们迄今看到的平均集成方法不同,提升法集成是迭代地构建估算器的。从初始集成中学到的知识被用于构建后继的估算器。这是提升法集成的主要缺点,无法实现并行化。将并行化放在一边,这种迭代的特性要求设置一个学习率。这有助于梯度下降算法更容易地达到损失函数的最小值。这里,我们使用 500 棵树,每棵树最多 3 个节点,并且学习率为 0.01
。此外,这里使用的是最小二乘法(LS)损失,类似于均方误差(MSE)。稍后会详细介绍可用的损失函数:
from sklearn.ensemble import GradientBoostingRegressor
rgr = GradientBoostingRegressor(
n_estimators=1000, learning_rate=0.01, max_depth=3, loss='ls'
)
rgr.fit(x_train, y_train)
y_test_pred = rgr.predict(x_test)
这个新算法在测试集上的表现如下:
# R2: 0.92, MSE: 3.93, RMSE: 1.98, MAE: 1.42
如你所见,这个设置相比随机森林给出了更低的 MSE,而随机森林则有更好的 MAE。梯度提升回归器还可以使用另一种损失函数——最小绝对偏差(LAD),这类似于 MAE。LAD 在处理异常值时可能会有所帮助,并且有时能减少模型在测试集上的 MAE 表现。然而,它并没有改善当前数据集的 MAE 表现。我们还有一个百分位数(分位数)损失函数,但在深入了解支持的损失函数之前,我们需要先学会如何诊断学习过程。**
这里需要设置的主要超参数包括树的数量、树的深度、学习率和损失函数。根据经验法则,应该设定更高的树的数量和较低的学习率。正如我们稍后会看到的,这两个超参数是相互反比的。控制树的深度完全取决于你的数据。一般来说,我们需要使用较浅的树,并通过提升法增强它们的效果。然而,树的深度控制着我们希望捕捉到的特征交互的数量。在一个树桩(只有一个分裂的树)中,每次只能学习一个特征。较深的树则类似于嵌套的if
条件,每次有更多的特征参与其中。我通常会从max_depth
设定为大约3
和5
开始,并在后续调整。
绘制学习偏差图
随着每次添加估计器,我们预计算法会学习得更多,损失也会减少。然而,在某个时刻,额外的估计器将继续对训练数据进行过拟合,而对测试数据的改进不大。
为了更清楚地了解情况,我们需要将每次添加的估计器计算出的损失绘制出来,分别针对训练集和测试集。对于训练损失,梯度提升元估计器会将其保存在loss_
属性中。对于测试损失,我们可以使用元估计器的staged_predict()
方法。该方法可以用于给定数据集,在每次中间迭代时进行预测。
由于我们有多种损失函数可以选择,梯度提升还提供了一个loss_()
方法,根据所用的损失函数计算损失。在这里,我们创建了一个新函数,用于计算每次迭代的训练和测试误差,并将它们放入数据框中:
def calculate_deviance(estimator, x_test, y_test):
train_errors = estimator.train_score_
test_errors = [
estimator.loss_(y_test, y_pred_staged)
for y_pred_staged in estimator.staged_predict(x_test)
]
return pd.DataFrame(
{
'n_estimators': range(1, estimator.estimators_.shape[0]+1),
'train_error': train_errors,
'test_error': test_errors,
}
).set_index('n_estimators')
由于我们这里将使用最小二乘损失(LS loss),你可以简单地用mean_squared_error()
方法替代estimator.loss_()
,得到完全相同的结果。但为了代码的更高灵活性和可重用性,我们保留estimator.loss_()
函数。
接下来,我们像往常一样训练我们的梯度提升回归模型:
from sklearn.ensemble import GradientBoostingRegressor
rgr = GradientBoostingRegressor(n_estimators=250, learning_rate=0.02, loss='ls')
rgr.fit(x_train, y_train)
然后,我们使用训练好的模型和测试集,绘制训练和测试的学习偏差:
fig, ax = plt.subplots(1, 1, figsize=(16, 5), sharey=False)
df_deviance = calculate_deviance(rgr, x_test, y_test)
df_deviance['train_error'].plot(
kind='line', color='k', linestyle=':', ax=ax
)
df_deviance['test_error'].plot(
kind='line', color='k', linestyle='-', ax=ax
)
fig.show()
运行代码会得到如下图表:
这张图的美妙之处在于,它告诉我们测试集上的改进在大约120
个估计器后停止了,尽管训练集上的改进持续不断;也就是说,它开始过拟合了。此外,我们可以通过这张图理解所选学习率的效果,就像我们在第七章,《神经网络 - 深度学习来临》中所做的那样。
比较学习率设置
这次,我们不会只训练一个模型,而是训练三个梯度提升回归模型,每个模型使用不同的学习率。然后,我们将并排绘制每个模型的偏差图,如下所示:
与其他基于梯度下降的模型一样,高学习率会导致估计器过度调整,错过局部最小值。我们可以在第一张图中看到这一点,尽管有连续的迭代,但没有看到改进。第二和第三张图中的学习率看起来合理。相比之下,第三张图中的学习率似乎对于模型在 500 次迭代内收敛来说太慢了。你可以决定增加第三个模型的估计器数量,让它能够收敛。
我们从袋装集成方法中学到,通过为每个估计器使用一个随机训练样本,可以帮助减少过拟合。在下一节中,我们将看看相同的方法是否也能帮助提升集成方法。
使用不同的样本大小
我们一直在为每次迭代使用整个训练集。这一次,我们将训练三个梯度提升回归模型,每个模型使用不同的子样本大小,并像之前一样绘制它们的偏差图。我们将使用固定的学习率0.01
,并使用 LAD作为我们的损失函数,如下所示:
在第一张图中,每次迭代都会使用整个训练样本。因此,训练损失不像其他两张图那样波动。然而,第二个模型中使用的采样方法使其尽管损失图较为噪声,仍然达到了更好的测试得分。第三个模型的情况也类似,但最终误差略大。
提前停止并调整学习率
n_iter_no_change
超参数用于在一定数量的迭代后停止训练过程,前提是验证得分没有得到足够的改进。用于验证的子集,validation_fraction
,用于计算验证得分。tol
超参数用于决定我们认为多少改进才算足够。
**梯度提升算法中的 fit
方法接受一个回调函数,该函数会在每次迭代后被调用。它还可以用于设置基于自定义条件的训练停止条件。此外,它还可以用于监控或进行其他自定义设置。该回调函数接受三个参数:当前迭代的顺序(n
)、梯度提升实例(estimator
)以及它的设置(params
)。为了演示这个回调函数是如何工作的,我们构建了一个函数,在每 10 次迭代时将学习率更改为0.01
,其余迭代保持为0.1
,如下所示:
def lr_changer(n, estimator, params):
if n % 10:
estimator.learning_rate = 0.01
else:
estimator.learning_rate = 0.1
return False
然后,我们使用lr_changer
函数,如下所示:
from sklearn.ensemble import GradientBoostingRegressor
rgr = GradientBoostingRegressor(n_estimators=50, learning_rate=0.01, loss='ls')
rgr.fit(x_train, y_train, monitor=lr_changer)
现在,如果像我们通常做的那样打印偏差,我们会看到每隔第 10^(th) 次迭代后,由于学习率的变化,计算的损失值会跳跃:
我刚才做的事情几乎没有什么实际用途,但它展示了你手头的可能性。例如,你可以借鉴神经网络中求解器的自适应学习率和动量等思想,并通过此回调函数将其融入到这里。
回归范围
“我尽量做一个现实主义者,而不是悲观主义者或乐观主义者。”
–尤瓦尔·诺亚·哈拉里
梯度提升回归为我们提供的最后一个宝贵资源是回归范围。这对于量化预测的不确定性非常有用。
我们尽力让我们的预测与实际数据完全一致。然而,我们的数据可能仍然是嘈杂的,或者使用的特征可能并未捕捉到完整的真相。请看下面的例子:
x[1] | x[2] | y |
---|---|---|
0 | 0 | 10 |
1 | 1 | 50 |
0 | 0 | 20 |
0 | 0 | 22 |
考虑一个新的样本,x[1] = 0 且 x[2] = 0。我们已经有三个具有相同特征的训练样本,那么这个新样本的预测 y 值是多少呢?如果在训练过程中使用平方损失函数,则预测的目标将接近17.3
,即三个相应目标(10
,20
,22
)的均值。现在,如果使用 MAE(平均绝对误差)的话,预测的目标会更接近22
,即三个相应目标的中位数(50^(th) 百分位)。而不是 50^(th) 百分位,我们可以在使用分位数损失函数时使用其他任何百分位数。因此,为了实现回归范围,我们可以使用两个回归器,分别用两个不同的分位数作为我们范围的上下限。
*尽管回归范围在数据维度无关的情况下有效,但页面格式迫使我们用一个二维示例来提供更清晰的展示。以下代码创建了 400 个样本以供使用:
x_sample = np.arange(-10, 10, 0.05)
y_sample = np.random.normal(loc=0, scale=25, size=x_sample.shape[0])
y_sample *= x_sample
pd_random_samples = pd.DataFrame(
{
'x': x_sample,
'y': y_sample
}
)
这里是生成的 y 与 x 值的散点图:
现在,我们可以训练两个回归模型,使用第 10 百分位数和第 90 百分位数作为我们的范围边界,并绘制这些回归边界,以及我们的散点数据点:
from sklearn.ensemble import GradientBoostingRegressor
fig, ax = plt.subplots(1, 1, figsize=(12, 8), sharey=False)
pd_random_samples.plot(
title='Regression Ranges [10th & 90th Quantiles]',
kind='scatter', x='x', y='y', color='k', alpha=0.95, ax=ax
)
for quantile in [0.1, 0.9]:
rgr = GradientBoostingRegressor(n_estimators=10, loss='quantile', alpha=quantile)
rgr.fit(pd_random_samples[['x']], pd_random_samples['y'])
pd_random_samples[f'pred_q{quantile}'] = rgr.predict(pd_random_samples[['x']])
pd_random_samples.plot(
kind='line', x='x', y=f'pred_q{quantile}',
linestyle='-', alpha=0.75, color='k', ax=ax
)
ax.legend(ncol=1, fontsize='x-large', shadow=True)
fig.show()
我们可以看到,大部分数据点落在了范围内。理想情况下,我们希望 80%的数据点落在90-100的范围内:
我们现在可以使用相同的策略来预测汽车价格:
from sklearn.ensemble import GradientBoostingRegressor
rgr_min = GradientBoostingRegressor(n_estimators=50, loss='quantile', alpha=0.25)
rgr_max = GradientBoostingRegressor(n_estimators=50, loss='quantile', alpha=0.75)
rgr_min.fit(x_train, y_train, monitor=lr_changer)
rgr_max.fit(x_train, y_train, monitor=lr_changer)
y_test_pred_min = rgr_min.predict(x_test)
y_test_pred_max = rgr_max.predict(x_test)
df_pred_range = pd.DataFrame(
{
'Actuals': y_test,
'Pred_min': y_test_pred_min,
'Pred_max': y_test_pred_max,
}
)
然后,我们可以检查测试集中的多少百分比数据点落在回归范围内:
df_pred_range['Actuals in Range?'] = df_pred_range.apply(
lambda row: 1 if row['Actuals'] >= row['Pred_min'] and row['Actuals'] <= row['Pred_max'] else 0, axis=1
)
计算df_pred_range['Actuals in Range?']
的平均值为0.49
,这个值非常接近我们预期的0.5
。显然,根据我们的使用场景,我们可以使用更宽或更窄的范围。如果你的模型将用于帮助车主出售汽车,你可能需要给出合理的范围,因为告诉某人他们可以以$5 到$30,000 之间的任何价格出售汽车,虽然很准确,但并没有多大帮助。有时候,一个不那么精确但有用的模型,比一个准确却无用的模型要好。
另一个如今使用较少的提升算法是 AdaBoost 算法。为了完整性,我们将在下一节简要探讨它。
使用 AdaBoost 集成方法
在 AdaBoost 集成中,每次迭代中所犯的错误被用来调整训练样本的权重,以便用于后续迭代。与提升元估计器一样,这种方法也可以使用其他任何估计器,而不仅限于默认使用的决策树。这里,我们使用默认的估计器在汽车数据集上进行训练:
from sklearn.ensemble import AdaBoostRegressor
rgr = AdaBoostRegressor(n_estimators=100)
rgr.fit(x_train, y_train)
y_test_pred = rgr.predict(x_test)
AdaBoost 元估计器也有一个staged_predict
方法,允许我们在每次迭代后绘制训练或测试损失的改善情况。以下是绘制测试误差的代码:
pd.DataFrame(
[
(n, mean_squared_error(y_test, y_pred_staged))
for n, y_pred_staged in enumerate(rgr.staged_predict(x_test), 1)
],
columns=['n', 'Test Error']
).set_index('n').plot()
fig.show()
这是每次迭代后计算损失的图表:
与其他集成方法一样,我们添加的估计器越多,模型的准确度就越高。一旦我们开始过拟合,就应该停止。因此,拥有一个验证样本对于确定何时停止非常重要。这里我使用了测试集进行演示,但在实际应用中,测试样本应该保持单独,并使用验证集来代替。
探索更多的集成方法
目前为止,我们已经看过的主要集成技术就是这些。接下来的一些技术也值得了解,并且在一些特殊情况下可能会有用。
投票集成方法
有时,我们有多个优秀的估计器,每个估计器都有自己的错误。我们的目标不是减小它们的偏差或方差,而是结合它们的预测,希望它们不会犯同样的错误。在这种情况下,可以使用VotingClassifier
和VotingRegressor
。你可以通过调整weights
超参数,给某些估计器更高的优先权。VotingClassifier
有不同的投票策略,取决于是否使用预测的类别标签,或者是否应该使用预测的概率。
堆叠集成
与其投票,你可以通过增加一个额外的估计器,将多个估计器的预测结果结合起来,作为其输入。这个策略被称为堆叠。最终估计器的输入可以仅限于先前估计器的预测,或者可以是它们的预测与原始训练数据的结合。为了避免过拟合,最终估计器通常使用交叉验证进行训练。
随机树嵌入
我们已经看到树能够捕捉数据中的非线性特征。因此,如果我们仍然希望使用更简单的算法,我们可以仅使用树来转换数据,并将预测交给简单的算法来完成。在构建树时,每个数据点都会落入其中一个叶节点。因此,叶节点的 ID 可以用来表示不同的数据点。如果我们构建多个树,那么每个数据点就可以通过它在每棵树中所落叶节点的 ID 来表示。这些叶节点 ID 可以作为新的特征,输入到更简单的估计器中。这种嵌入方法对于特征压缩非常有用,并且允许线性模型捕捉数据中的非线性特征。
在这里,我们使用无监督的RandomTreesEmbedding
方法来转换我们的汽车特征,然后在Ridge
回归中使用转换后的特征:
from sklearn.ensemble import RandomTreesEmbedding
from sklearn.linear_model import Ridge
from sklearn.pipeline import make_pipeline
rgr = make_pipeline(RandomTreesEmbedding(), Ridge())
rgr.fit(x_train, y_train)
y_test_pred = rgr.predict(x_test)
print(f'MSE: {mean_squared_error(y_test, y_test_pred)}')
从前面的代码块中,我们可以观察到以下几点:
-
这种方法不限于
RandomTreesEmbedding
。 -
梯度提升树也可以用于转换数据,供下游估计器使用。
-
GradientBoostingRegressor
和GradientBoostingClassifier
都有一个apply
函数,可用于特征转换。
总结
在本章中,我们了解了算法如何从以集成的形式组装中受益。我们学习了这些集成如何缓解偏差与方差的权衡。
在处理异构数据时,梯度提升和随机森林算法是我进行分类和回归时的首选。由于它们依赖于树结构,它们不需要任何复杂的数据预处理。它们能够处理非线性数据并捕捉特征之间的交互。最重要的是,它们的超参数调整非常简单。
每种方法中的估计器越多越好,你不需要过于担心它们会过拟合。至于梯度提升方法,如果你能承受更多的树木,可以选择较低的学习率。除了这些超参数外,每个算法中树的深度应该通过反复试验和交叉验证来调优。由于这两种算法来自偏差-方差谱的不同端点,你可以最初选择拥有大树的森林,并在之后进行修剪。相反,你也可以从浅层树开始,并依赖你的梯度提升元估计器来增强它们。
到目前为止,在本书中我们每次只预测一个目标。比如说,我们预测了汽车的价格,仅此而已。在下一章,我们将看到如何一次性预测多个目标。此外,当我们的目标是使用分类器给出的概率时,拥有一个校准过的分类器至关重要。如果我们能得到可信的概率,我们就能更好地评估我们的风险。因此,校准分类器将是下一章将要讨论的另一个话题。