PyTorch-与-Sklearn-机器学习指南-四-

169 阅读1小时+

PyTorch 与 Sklearn 机器学习指南(四)

原文:zh.annas-archive.org/md5/2a872f7dd98f6fbe3043a236f689e451

译者:飞龙

协议:CC BY-NC-SA 4.0

第十章:使用未标记数据进行工作——聚类分析

在前几章中,我们使用监督学习技术构建机器学习模型,使用的数据是已知答案的数据——在我们的训练数据中,类标签已经是可用的。在本章中,我们将转向探索聚类分析,这是一类无监督学习技术,允许我们在数据中发现隐藏的结构,我们并不预先知道正确答案。聚类的目标是在数据中找到自然的分组,使得同一聚类中的项彼此之间的相似性比与不同聚类中的项更高。

鉴于其探索性质,聚类是一个令人兴奋的话题,在本章中,您将学习以下概念,这些概念可以帮助我们将数据组织成有意义的结构:

  • 使用流行的k-means算法找到相似性中心

  • 采用自下而上的方法构建层次聚类树

  • 使用基于密度的聚类方法识别对象的任意形状

使用 k-means 将对象按相似性分组

在本节中,我们将学习其中一种最流行的聚类算法之一,即 k-means,它在学术界以及工业界广泛使用。聚类(或聚类分析)是一种技术,允许我们找到彼此相关性更高的相似对象组。聚类的业务应用示例包括按不同主题对文档、音乐和电影进行分组,或者基于共同购买行为找到具有相似兴趣的客户作为推荐引擎的基础。

使用 scikit-learn 进行 k-means 聚类

正如您将在接下来看到的,k-means 算法非常易于实现,但与其他聚类算法相比,在计算效率上也非常高,这也许解释了它的流行性。k-means 算法属于原型聚类的范畴。

我们稍后将讨论另外两种聚类方法,层次聚类基于密度的聚类,在本章的后面部分。

原型聚类意味着每个聚类由一个原型表示,通常是具有连续特征的相似点的质心平均值),或者在分类特征情况下的中心点(最具代表性或者最小化到属于特定聚类的所有其他点之间距离的点)。虽然 k-means 在识别球形聚类方面非常出色,但这种聚类算法的缺点之一是我们必须预先指定聚类数k。不恰当的k选择可能导致聚类性能不佳。本章后面,我们将讨论肘部方法和轮廓图,这些是评估聚类质量的有用技术,帮助我们确定最优聚类数k

虽然 k 均值聚类可以应用于高维数据,但出于可视化目的,我们将通过一个简单的二维数据集来演示以下示例:

>>> from sklearn.datasets import make_blobs
>>> X, y = make_blobs(n_samples=150,
...                   n_features=2,
...                   centers=3,
...                   cluster_std=0.5,
...                   shuffle=True,
...                   random_state=0)
>>> import matplotlib.pyplot as plt
>>> plt.scatter(X[:, 0],
...             X[:, 1],
...             c='white',
...             marker='o',
...             edgecolor='black',
...             s=50)
>>> plt.xlabel('Feature 1')
>>> plt.ylabel('Feature 2')
>>> plt.grid()
>>> plt.tight_layout()
>>> plt.show() 

我们刚刚创建的数据集包含了 150 个随机生成的点,大致分成了三个密度较高的区域,这通过二维散点图进行了可视化:

Chart, scatter chart  Description automatically generated

图 10.1:我们未标记数据集的散点图

在聚类的实际应用中,我们没有任何关于这些示例的地面真实类别信息(作为实证证据而非推断提供的信息);如果我们有类标签,这个任务就属于监督学习的范畴。因此,我们的目标是根据它们的特征相似性对这些示例进行分组,这可以通过使用 k 均值算法来实现,如下所总结的四个步骤:

  1. 从示例中随机选择k个质心作为初始聚类中心

  2. 将每个示例分配到最近的质心,

  3. 将质心移动到分配给它的示例的中心

  4. 重复步骤 23直到簇分配不再改变或达到用户定义的容差或最大迭代次数

现在,下一个问题是,我们如何衡量对象之间的相似性?我们可以将相似性定义为距离的相反数,对于具有连续特征的聚类示例,常用的距离是欧氏距离的平方,即在m维空间中两点xy之间的距离:

注意,在前述方程中,指数j指的是示例输入的第j维(特征列),xy。在本节的其余部分,我们将使用上标ij来分别指代示例(数据记录)和簇索引的索引。

基于这个欧氏距离度量,我们可以将 k 均值算法描述为一个简单的优化问题,这是一种迭代方法,用于最小化簇内平方误差和SSE),有时也称为簇惯性

这里,是簇j的代表点(质心)。w^(^i^(, )^j^) = 1 如果示例x^(^i^)在簇j中,否则为 0。

现在您已经了解了简单的 k 均值算法的工作原理,让我们使用 scikit-learn 的cluster模块中的KMeans类将其应用于我们的示例数据集:

>>> from sklearn.cluster import KMeans
>>> km = KMeans(n_clusters=3,
...             init='random',
...             n_init=10,
...             max_iter=300,
...             tol=1e-04,
...             random_state=0)
>>> y_km = km.fit_predict(X) 

使用前述代码,我们将所需聚类数设置为3;需要事先指定聚类数是 k-means 的限制之一。我们设置n_init=10,以独立运行 k-means 聚类算法 10 次,每次选择不同的随机质心,选择最终模型为 SSE 最低的一个。通过max_iter参数,我们指定每次单独运行的最大迭代次数(这里是300)。请注意,如果 scikit-learn 中的 k-means 实现在达到最大迭代次数之前已经收敛,它将会提前停止。然而,有可能 k-means 在特定运行中无法收敛,这可能是一个问题(计算上昂贵),特别是当我们为max_iter选择相对较大的值时。解决收敛问题的一种方法是选择更大的tol值,这是一个控制在群内 SSE 变化方面宣布收敛的参数。在前述代码中,我们选择了1e-04(=0.0001)的容差。

k-means 的一个问题是一个或多个聚类可能为空。请注意,这个问题在 k-medoids 或模糊 C 均值算法中并不存在,我们稍后将在本节讨论这个算法。然而,在 scikit-learn 中的当前 k-means 实现中已经解决了这个问题。如果一个聚类为空,算法将会寻找离空聚类质心最远的样本,然后将质心重新分配为这个最远的点。

特征缩放

当我们将 k-means 应用于现实世界的数据时,使用欧氏距离度量,我们希望确保特征在相同的尺度上测量,并在必要时应用 z-score 标准化或最小-最大缩放。

预测了聚类标签y_km之后,并讨论了 k-means 算法的一些挑战,现在让我们来可视化 k-means 在数据集中识别出的聚类以及聚类中心。这些信息存储在已拟合的KMeans对象的cluster_centers_属性下:

>>> plt.scatter(X[y_km == 0, 0],
...             X[y_km == 0, 1],
...             s=50, c='lightgreen',
...             marker='s', edgecolor='black',
...             label='Cluster 1')
>>> plt.scatter(X[y_km == 1, 0],
...             X[y_km == 1, 1],
...             s=50, c='orange',
...             marker='o', edgecolor='black',
...             label='Cluster 2')
>>> plt.scatter(X[y_km == 2, 0],
...             X[y_km == 2, 1],
...             s=50, c='lightblue',
...             marker='v', edgecolor='black',
...             label='Cluster 3')
>>> plt.scatter(km.cluster_centers_[:, 0],
...             km.cluster_centers_[:, 1],
...             s=250, marker='*',
...             c='red', edgecolor='black',
...             label='Centroids')
>>> plt.xlabel('Feature 1')
>>> plt.ylabel('Feature 2')
>>> plt.legend(scatterpoints=1)
>>> plt.grid()
>>> plt.tight_layout()
>>> plt.show() 

图 10.2中,你可以看到 k-means 将三个质心放置在每个球体的中心位置,这看起来是对数据集合理的分组:

图表,散点图 自动生成的描述

图 10.2:k-means 聚类及其质心

尽管 k-means 在这个玩具数据集上表现良好,但我们仍然存在一个缺点,即需要事先指定簇的数量k。在现实应用中,特别是当我们处理无法可视化的高维数据集时,要选择的簇数并不总是那么明显。k-means 的其他特性包括簇不重叠,不具有层次结构,同时我们还假设每个簇中至少有一项。在本章后面,我们将遇到不同类型的聚类算法,包括分层和基于密度的聚类。这两种算法都不要求我们预先指定簇的数量或假设数据集中存在球形结构。

在下一小节中,我们将介绍经典 k-means 算法的一种流行变体称为k-means++。虽然它没有解决前一段讨论的 k-means 的假设和缺点,但通过更智能地选择初始聚类中心,它可以极大地改善聚类结果。

使用 k-means++更智能地放置初始簇质心

到目前为止,我们已经讨论了经典的 k-means 算法,该算法使用随机种子来放置初始质心,如果选择的初始质心不好,有时可能导致簇的不良聚类或收敛缓慢。解决这个问题的一种方法是在数据集上多次运行 k-means 算法,并选择在 SSE 方面表现最佳的模型。

另一种策略是通过 k-means++算法将初始质心放置在彼此远离的位置,这比经典的 k-means 方法(k-means++:仔细种子的优势D. ArthurS. Vassilvitskii第十八届年度 ACM-SIAM 离散算法研讨会论文集中提出,页面 1027-1035. 工业和应用数学学会,2007 年)可以得到更好和更一致的结果。

k-means++中的初始化可以总结如下:

  1. 初始化一个空集合,M,用于存储正在选择的k个质心。

  2. 随机选择第一个质心,,从输入示例中,并将其分配给M

  3. 对于不在M中的每个示例,x^(^i^),找到到M中任何质心的最小平方距离,d(x^(^i^), M)²。

  4. 要随机选择下一个质心,,使用等于的加权概率分布。例如,我们将所有点收集到一个数组中,并选择加权随机抽样,使得距离平方较大的点更有可能被选择为质心。

  5. 重复步骤 34,直到选择k个质心。

  6. 继续使用经典的 k-means 算法。

要在 scikit-learn 的KMeans对象中使用 k-means++,我们只需将init参数设置为'k-means++'。实际上,'k-means++'init参数的默认参数,强烈推荐在实践中使用。之前的示例中没有使用它的唯一原因是为了不一次引入太多概念。本节的其余部分将使用 k-means++,但鼓励您更多地尝试这两种不同的方法(通过init='random'进行经典的 k-means 或通过init='k-means++'进行 k-means++)来放置初始簇质心。

硬聚类与软聚类

硬聚类描述了一个算法族,其中数据集中的每个示例被分配到一个且仅一个簇中,就像我们之前在本章讨论过的 k-means 和 k-means++ 算法一样。相反,软聚类(有时也称为模糊聚类)的算法将一个示例分配给一个或多个簇。软聚类的一个流行示例是模糊 C 均值FCM)算法(也称为软 k-means模糊 k-means)。最初的想法可以追溯到 20 世纪 70 年代,当时 Joseph C. Dunn 首次提出了模糊聚类的早期版本,以改进 k-means(A Fuzzy Relative of the ISODATA Process and Its Use in Detecting Compact Well-Separated Clusters, 1973)。几乎 10 年后,James C. Bezdek 发表了他关于改进模糊聚类算法的工作,现在被称为 FCM 算法(Pattern Recognition with Fuzzy Objective Function Algorithms, Springer Science+Business Media, 2013)。

FCM 过程与 k-means 非常相似。但是,我们用每个点属于每个簇的概率替换了硬聚类分配。在 k-means 中,我们可以用稀疏的二进制值向量表示示例x的簇成员资格。

在这里,索引位置为 1 的值表示簇质心,,示例被分配到(假设k = 3,)。相比之下,在 FCM 中,成员向量可以表示如下:

在这里,每个值都落在范围[0, 1]内,表示相应簇质心的成员概率。给定示例的成员总和等于 1。与 k-means 算法类似,我们可以用四个关键步骤总结 FCM 算法:

  1. 指定k个质心,并为每个点随机分配簇成员资格。

  2. 计算簇质心,

  3. 更新每个点的簇成员资格

  4. 重复步骤 23,直到成员系数不再改变或达到用户定义的容差或最大迭代次数。

FCM 的目标函数——我们将其缩写为J[m]——看起来与我们在 k-means 中最小化的簇内平方和误差(SSE)非常相似:

但是,请注意,成员指示器* w ^(^i^(,^j^)不像 k 均值中的二进制值(),而是一个表示聚类成员资格概率的实数值()。您可能还注意到,我们为 w ^(^i^(,^j^)添加了一个额外的指数;指数m*,大于或等于 1(通常m=2),被称为模糊系数(或简称模糊化器),它控制模糊度的程度。

m值越大,聚类成员资格* w ^(^i^(,^j^)*的值越小,这导致聚类变得更加模糊。聚类成员资格概率本身的计算方法如下:

例如,在前面的 k 均值示例中,如果我们选择了三个聚类中心,我们可以计算属于聚类的成员资格如下:

聚类中心本身是通过加权平均所有示例来计算的,加权系数是每个示例属于该聚类的程度():

光看计算聚类成员资格的方程,我们可以说 FCM 中的每次迭代比 k 均值中的迭代更昂贵。另一方面,FCM 通常需要更少的迭代才能达到收敛。然而,实际上发现,k 均值和 FCM 产生非常相似的聚类输出,正如一项研究(《比较分析 k 均值和模糊 c 均值算法》,由S. GhoshS. K. DubeyIJACSA,4: 35–38,2013 年)所描述的那样。不幸的是,目前 scikit-learn 中未实现 FCM 算法,但有兴趣的读者可以尝试来自 scikit-fuzzy 软件包的 FCM 实现,该软件包可在github.com/scikit-fuzzy/scikit-fuzzy获取。

使用肘方法找到最优聚类数

无监督学习的主要挑战之一是我们不知道确切的答案。在我们的数据集中,我们没有地面真实类标签,这些标签允许我们应用在第六章 学习模型评估和超参数调整的最佳实践 中使用的技术来评估监督模型的性能。因此,为了量化聚类的质量,我们需要使用内在度量标准,例如在集群内的 SSE(畸变)来比较不同 k 均值聚类模型的性能。

方便地,当我们使用 scikit-learn 时,我们不需要显式计算在集群内的 SSE,因为在拟合了KMeans模型后,它已经通过inertia_属性访问:

>>> print(f'Distortion: {km.inertia_:.2f}')
Distortion: 72.48 

基于聚类内 SSE,我们可以使用一个名为 肘部法 的图形工具来估计给定任务的最佳聚类数 k。我们可以说,如果 k 增加,失真将减少。这是因为示例将更接近它们被分配到的质心。肘部法的思想是识别失真开始最快增加的 k 值,如果我们为不同的 k 值绘制失真图,这将变得更清晰:

>>> distortions = []
>>> for i in range(1, 11):
...     km = KMeans(n_clusters=i,
...                 init='k-means++',
...                 n_init=10,
...                 max_iter=300,
...                 random_state=0)
...     km.fit(X)
...     distortions.append(km.inertia_)
>>> plt.plot(range(1,11), distortions, marker='o')
>>> plt.xlabel('Number of clusters')
>>> plt.ylabel('Distortion')
>>> plt.tight_layout()
>>> plt.show() 

正如您在 图 10.3 中所看到的,肘部 位于 k = 3,这是支持 k = 3 对于此数据集确实是一个好选择的证据:

图 10.3:使用肘部法找到最佳聚类数

通过轮廓图量化聚类质量

另一个评估聚类质量的内在度量是 轮廓分析,它也可以应用于我们稍后将讨论的除 k-means 外的其他聚类算法。轮廓分析可用作绘制集群示例紧密程度的度量的图形工具。要计算数据集中单个示例的 轮廓系数,我们可以应用以下三个步骤:

  1. 计算 聚类内聚性 a^(^i^) ,作为示例 x^(^i^) 和同一聚类中所有其他点之间的平均距离。

  2. 计算 聚类分离度 b^(^i^) ,作为示例 x^(^i^) 和最近聚类中所有示例之间的平均距离。

  3. 计算轮廓系数 s^(^i^) ,如下所示,作为聚类内聚性和分离性之间差异的差值,除以两者中的较大者:

轮廓系数的范围为 -1 到 1。根据前述方程,我们可以看到如果聚类分离和内聚相等(b^(^i^) = a^(^i^)),则轮廓系数为 0。此外,如果 b^(^i^) >> a^(^i^),我们接近理想的轮廓系数 1,因为 b^(^i^) 量化了示例与其他聚类的不相似性,而 a^(^i^) 告诉我们它与同一聚类中其他示例的相似性。

silhouette_samples 是 scikit-learn 的 metric 模块提供的轮廓系数,可选地,为了方便起见,可以导入 silhouette_scores 函数。silhouette_scores 函数计算所有示例的平均轮廓系数,这相当于 numpy.mean(silhouette_samples(...))。通过执行以下代码,我们现在将创建一个 k-means 聚类的轮廓系数图,其中 k = 3:

>>> km = KMeans(n_clusters=3,
...             init='k-means++',
...             n_init=10,
...             max_iter=300,
...             tol=1e-04,
...             random_state=0)
>>> y_km = km.fit_predict(X)
>>> import numpy as np
>>> from matplotlib import cm
>>> from sklearn.metrics import silhouette_samples
>>> cluster_labels = np.unique(y_km)
>>> n_clusters = cluster_labels.shape[0]
>>> silhouette_vals = silhouette_samples(
...     X, y_km, metric='euclidean'
... )
>>> y_ax_lower, y_ax_upper = 0, 0
>>> yticks = []
>>> for i, c in enumerate(cluster_labels):
...     c_silhouette_vals = silhouette_vals[y_km == c]
...     c_silhouette_vals.sort()
...     y_ax_upper += len(c_silhouette_vals)
...     color = cm.jet(float(i) / n_clusters)
...     plt.barh(range(y_ax_lower, y_ax_upper),
...              c_silhouette_vals,
...              height=1.0,
...              edgecolor='none',
...              color=color)
...     yticks.append((y_ax_lower + y_ax_upper) / 2.)
...     y_ax_lower += len(c_silhouette_vals)
>>> silhouette_avg = np.mean(silhouette_vals)
>>> plt.axvline(silhouette_avg,
...             color="red",
...             linestyle="--")
>>> plt.yticks(yticks, cluster_labels + 1)
>>> plt.ylabel('Cluster')
>>> plt.xlabel('Silhouette coefficient')
>>> plt.tight_layout()
>>> plt.show() 

通过对轮廓图的视觉检查,我们可以快速审查不同聚类的大小,并识别包含 异常值 的聚类:

图 10.4:一个良好聚类的轮廓图示例

然而,如前面的轮廓图所示,轮廓系数与平均轮廓分数并不接近,并且在本例中,这是聚类的指标。此外,为了总结我们聚类的好坏,我们将平均轮廓系数添加到图中(虚线)。

要查看相对糟糕聚类的轮廓图是什么样子,请用仅两个质心种子化 k 均值算法:

>>> km = KMeans(n_clusters=2,
...             init='k-means++',
...             n_init=10,
...             max_iter=300,
...             tol=1e-04,
...             random_state=0)
>>> y_km = km.fit_predict(X)
>>> plt.scatter(X[y_km == 0, 0],
...             X[y_km == 0, 1],
...             s=50, c='lightgreen',
...             edgecolor='black',
...             marker='s',
...             label='Cluster 1')
>>> plt.scatter(X[y_km == 1, 0],
...             X[y_km == 1, 1],
...             s=50,
...             c='orange',
...             edgecolor='black',
...             marker='o',
...             label='Cluster 2')
>>> plt.scatter(km.cluster_centers_[:, 0],
...             km.cluster_centers_[:, 1],
...             s=250,
...             marker='*',
...             c='red',
...             label='Centroids')
>>> plt.xlabel('Feature 1')
>>> plt.ylabel('Feature 2')
>>> plt.legend()
>>> plt.grid()
>>> plt.tight_layout()
>>> plt.show() 

如您在图 10.5中所见,三个球形数据组之间有一个质心。

尽管聚类看起来并非完全糟糕,但仍然次优:

图表,散点图  自动生成的描述

图 10.5: 一个聚类次优示例

请记住,在真实世界的问题中,我们通常没有奢侈地在二维散点图中可视化数据集,因为我们通常使用更高维度的数据。因此,接下来,我们将创建轮廓图来评估结果:

>>> cluster_labels = np.unique(y_km)
>>> n_clusters = cluster_labels.shape[0]
>>> silhouette_vals = silhouette_samples(
...     X, y_km, metric='euclidean'
... )
>>> y_ax_lower, y_ax_upper = 0, 0
>>> yticks = []
>>> for i, c in enumerate(cluster_labels):
...     c_silhouette_vals = silhouette_vals[y_km == c]
...     c_silhouette_vals.sort()
...     y_ax_upper += len(c_silhouette_vals)
...     color = cm.jet(float(i) / n_clusters)
...     plt.barh(range(y_ax_lower, y_ax_upper),
...              c_silhouette_vals,
...              height=1.0,
...              edgecolor='none',
...              color=color)
...     yticks.append((y_ax_lower + y_ax_upper) / 2.)
...     y_ax_lower += len(c_silhouette_vals)
>>> silhouette_avg = np.mean(silhouette_vals)
>>> plt.axvline(silhouette_avg, color="red", linestyle="--")
>>> plt.yticks(yticks, cluster_labels + 1)
>>> plt.ylabel('Cluster')
>>> plt.xlabel('Silhouette coefficient')
>>> plt.tight_layout()
>>> plt.show() 

如您在图 10.6中所见,轮廓现在具有明显不同的长度和宽度,这表明相对糟糕或至少次优的聚类:

图 10.6: 一个聚类次优示例的轮廓图

现在,我们已经对聚类的工作原理有了很好的理解,接下来的部分将介绍层次聚类作为 k 均值的替代方法。

将群集组织成层次树

在本节中,我们将看一种基于原型的聚类的替代方法:层次聚类。层次聚类算法的一个优点是它允许我们绘制树状图(二叉层次聚类的可视化),这可以通过创建有意义的分类体系来帮助解释结果。这种层次方法的另一个优点是我们不需要预先指定群集的数量。

层次聚类的两种主要方法是聚合分裂层次聚类。在分裂层次聚类中,我们从包含完整数据集的一个群集开始,并迭代地将群集分成较小的群集,直到每个群集只包含一个示例。在本节中,我们将重点关注聚合聚类,它采用相反的方法。我们从每个示例作为单独的群集开始,并合并最接近的群集对,直到只剩下一个群集。

以自底向上的方式对群集进行分组

凝聚式层次聚类的两种标准算法是单链接完全链接。使用单链接时,我们计算每对聚类中最相似成员之间的距离,并合并两个距离最小的聚类。完全链接方法类似于单链接,但我们比较每对聚类中最不相似成员,以执行合并。这在 图 10.7 中显示:

图 10.7:完全链接方法

替代链接类型

用于凝聚式层次聚类的其他常用算法包括平均链接和 Ward 链接。在平均链接中,我们基于两个聚类中所有组成员的最小平均距离合并聚类对。在 Ward 链接中,合并导致总内部簇平方和增加最小的两个聚类。

在本节中,我们将专注于使用完全链接方法进行凝聚式聚类。层次完全链接聚类是一个迭代过程,可以总结为以下步骤:

  1. 计算所有示例的成对距离矩阵。

  2. 将每个数据点表示为单例聚类。

  3. 根据最不相似(最远)成员之间的距离合并两个最接近的聚类。

  4. 更新聚类链接矩阵。

  5. 重复 步骤 2-4 直到只剩下一个单一的聚类。

接下来,我们将讨论如何计算距离矩阵(步骤 1)。但首先,让我们生成一个随机数据样本来使用。行代表不同的观察(ID 0-4),列是这些示例的不同特征(XYZ):

>>> import pandas as pd
>>> import numpy as np
>>> np.random.seed(123)
>>> variables = ['X', 'Y', 'Z']
>>> labels = ['ID_0', 'ID_1', 'ID_2', 'ID_3', 'ID_4']
>>> X = np.random.random_sample([5, 3])*10
>>> df = pd.DataFrame(X, columns=variables, index=labels)
>>> df 

在执行上述代码后,我们现在应该看到包含随机生成示例的以下 DataFrame

图 10.8:一个随机生成的数据样本

在距离矩阵上执行层次聚类

要计算作为层次聚类算法输入的距离矩阵,我们将使用 SciPy 的 spatial.distance 子模块中的 pdist 函数:

>>> from scipy.spatial.distance import pdist, squareform
>>> row_dist = pd.DataFrame(squareform(
...                         pdist(df, metric='euclidean')),
...                         columns=labels, index=labels)
>>> row_dist 

使用上述代码,我们根据特征 XYZ 计算了数据集中每对输入示例之间的欧氏距离。

我们提供了由 pdist 返回的压缩距离矩阵作为 squareform 函数的输入,以创建成对距离的对称矩阵,如下所示:

图 10.9:我们数据的计算成对距离

接下来,我们将使用 SciPy 的 cluster.hierarchy 子模块中的 linkage 函数将完全链接聚合应用于我们的聚类,该函数返回所谓的链接矩阵

在调用 linkage 函数之前,让我们仔细查看函数文档:

>>> from scipy.cluster.hierarchy import linkage
>>> help(linkage)
[...]
Parameters:
  y : ndarray
    A condensed or redundant distance matrix. A condensed
    distance matrix is a flat array containing the upper
    triangular of the distance matrix. This is the form
    that pdist returns. Alternatively, a collection of m
    observation vectors in n dimensions may be passed as
    an m by n array.

  method : str, optional
    The linkage algorithm to use. See the Linkage Methods
    section below for full descriptions.

  metric : str, optional
    The distance metric to use. See the distance.pdist
    function for a list of valid distance metrics.

  Returns:
  Z : ndarray
    The hierarchical clustering encoded as a linkage matrix.
[...] 

根据函数描述,我们理解可以使用来自pdist函数的简化距离矩阵(上三角形式)作为输入属性。或者,我们也可以提供初始数据数组,并在linkage函数中使用'euclidean'度量作为函数参数。然而,我们不应该使用之前定义的squareform距离矩阵,因为它会产生与预期不同的距离值。总结来说,这里列出了三种可能的情景:

  • 错误的方法:如下所示使用squareform距离矩阵的代码片段会导致错误的结果:

    >>> row_clusters = linkage(row_dist,
    ...                        method='complete',
    ...                        metric='euclidean') 
    
  • 正确的方法:如下所示使用简化的距离矩阵的代码示例可以产生正确的联接矩阵:

    >>> row_clusters = linkage(pdist(df, metric='euclidean'),
    ...                        method='complete') 
    
  • 正确的方法:如下所示使用完整的输入示例矩阵(即所谓的设计矩阵)的代码片段也会导致与前述方法类似的正确联接矩阵:

    >>> row_clusters = linkage(df.values,
    ...                        method='complete',
    ...                        metric='euclidean') 
    

为了更仔细地查看聚类结果,我们可以将这些结果转换为 pandas DataFrame(最好在 Jupyter 笔记本中查看)如下所示:

>>> pd.DataFrame(row_clusters,
...              columns=['row label 1',
...                       'row label 2',
...                       'distance',
...                       'no. of items in clust.'],
...              index=[f'cluster {(i + 1)}' for i in
...                     range(row_clusters.shape[0])]) 

图 10.10所示,联接矩阵由多行组成,每行代表一个合并。第一列和第二列表示每个簇中最不相似的成员,第三列报告这些成员之间的距离。

最后一列返回每个簇中成员的计数:

img/B17582_10_10.png

图 10.10:联接矩阵

现在我们已经计算出联接矩阵,我们可以以树状图的形式可视化结果:

>>> from scipy.cluster.hierarchy import dendrogram
>>> # make dendrogram black (part 1/2)
>>> # from scipy.cluster.hierarchy import set_link_color_palette
>>> # set_link_color_palette(['black'])
>>> row_dendr = dendrogram(
...     row_clusters,
...     labels=labels,
...     # make dendrogram black (part 2/2)
...     # color_threshold=np.inf
... )
>>> plt.tight_layout()
>>> plt.ylabel('Euclidean distance')
>>> plt.show() 

如果您正在执行上述代码或阅读本书的电子书版本,您会注意到生成的树状图中的分支显示为不同颜色。颜色方案源自 Matplotlib 的颜色列表,这些颜色按照树状图中的距离阈值循环。例如,要将树状图显示为黑色,您可以取消注释前述代码中插入的相应部分:

img/B17582_10_11.png

图 10.11:我们数据的树状图

这样的树状图总结了在聚合层次聚类期间形成的不同簇;例如,您可以看到基于欧几里德距离度量,示例ID_0ID_4,接着是ID_1ID_2是最相似的。

附加树状图到热图

在实际应用中,层次聚类的树状图通常与热图结合使用,这使我们能够用颜色代码表示包含训练示例的数据数组或矩阵中的个别值。在本节中,我们将讨论如何将树状图附加到热图中并相应地对热图的行进行排序。

然而,将树状图附加到热图可能有些棘手,所以让我们一步步进行此过程:

  1. 我们创建一个新的 figure 对象,并通过 add_axes 属性定义树状图的x轴位置、y轴位置、宽度和高度。此外,我们将树状图逆时针旋转 90 度。代码如下:

    >>> fig = plt.figure(figsize=(8, 8), facecolor='white')
    >>> axd = fig.add_axes([0.09, 0.1, 0.2, 0.6])
    >>> row_dendr = dendrogram(row_clusters,
    ...                        orientation='left')
    >>> # note: for matplotlib < v1.5.1, please use
    >>> # orientation='right' 
    
  2. 接下来,我们根据从 dendrogram 对象(实质上是一个 Python 字典)通过 leaves 键访问的聚类标签重新排序我们初始 DataFrame 中的数据。代码如下:

    >>> df_rowclust = df.iloc[row_dendr['leaves'][::-1]] 
    
  3. 现在,我们从重新排序的 DataFrame 构建热图,并将其放置在树状图旁边:

    >>> axm = fig.add_axes([0.23, 0.1, 0.6, 0.6])
    >>> cax = axm.matshow(df_rowclust,
    ...                   interpolation='nearest',
    ...                   cmap='hot_r') 
    
  4. 最后,我们通过移除轴刻度和隐藏轴脊梁来修改树状图的美学。此外,我们添加了一个色条,并将特征和数据记录名称分配给xy轴刻度标签:

    >>> axd.set_xticks([])
    >>> axd.set_yticks([])
    >>> for i in axd.spines.values():
    ...     i.set_visible(False)
    >>> fig.colorbar(cax)
    >>> axm.set_xticklabels([''] + list(df_rowclust.columns))
    >>> axm.set_yticklabels([''] + list(df_rowclust.index))
    >>> plt.show() 
    

在执行前述步骤之后,热图应显示在附加的树状图上。

自动生成的图表描述

图 10.12:我们数据的热图和树状图

如您所见,热图中行的顺序反映了树状图中示例的聚类情况。除了简单的树状图外,热图中每个示例和特征的色彩编码值为我们提供了数据集的一个良好总结。

通过 scikit-learn 应用凝聚层次聚类

在前面的小节中,您看到了如何使用 SciPy 执行凝聚层次聚类。然而,scikit-learn 中也有一个 AgglomerativeClustering 实现,允许我们选择要返回的聚类数目。如果我们想要修剪层次聚类树,这将非常有用。

n_cluster 参数设置为 3 后,我们将使用与之前相同的完全连接方法和欧几里得距离度量将输入示例聚类成三组:

>>> from sklearn.cluster import AgglomerativeClustering
>>> ac = AgglomerativeClustering(n_clusters=3,
...                              affinity='euclidean',
...                              linkage='complete')
>>> labels = ac.fit_predict(X)
>>> print(f'Cluster labels: {labels}')
Cluster labels: [1 0 0 2 1] 

查看预测的聚类标签,我们可以看到第一个和第五个示例(ID_0ID_4)被分配到一个簇(标签 1),示例 ID_1ID_2 被分配到第二个簇(标签 0)。示例 ID_3 被放入其自己的簇(标签 2)。总体而言,这些结果与我们在树状图中观察到的结果一致。但是,需要注意的是,ID_3ID_4ID_0 更相似,而不是与 ID_1ID_2,如前面的树状图所示;这一点在 scikit-learn 的聚类结果中并不明显。现在让我们在以下代码片段中使用 n_cluster=2 重新运行 AgglomerativeClustering

>>> ac = AgglomerativeClustering(n_clusters=2,
...                              affinity='euclidean',
...                              linkage='complete')
>>> labels = ac.fit_predict(X)
>>> print(f'Cluster labels: {labels}')
Cluster labels: [0 1 1 0 0] 

如您所见,在这个修剪的聚类层次结构中,标签 ID_3 被分配到与 ID_0ID_4 相同的簇中,正如预期的那样。

通过 DBSCAN 定位高密度区域

尽管我们无法在本章节中涵盖大量不同的聚类算法,但至少让我们再包括一种聚类方法:基于密度的空间聚类应用与噪声DBSCAN),它不像 k-means 那样假设球形簇,也不将数据集分割成需要手动切断点的层次结构。DBSCAN 根据点的密集区域分配聚类标签。在 DBSCAN 中,密度的概念被定义为指定半径内的点数,如图所示:

根据 DBSCAN 算法,根据以下标准为每个示例(数据点)分配特殊标签:

  • 如果在指定半径内有至少指定数量(MinPts)的相邻点,则称该点为核心点,如图所示:

  • 边界点是指在半径内具有少于 MinPts 相邻点,但位于核心点的半径内的点。

  • 所有既不是核心点也不是边界点的其他点被视为噪声点

将点标记为核心点、边界点或噪声点之后,DBSCAN 算法可以总结为两个简单步骤:

  1. 形成每个核心点或连接的核心点组的单独簇。(如果它们的距离不超过。)

  2. 将每个边界点分配到其对应核心点的簇中。

在跳入实施之前,为了更好地理解 DBSCAN 的结果可能如何,让我们总结一下关于核心点、边界点和噪声点的内容,参见图 10.13

图 10.13:DBSCAN 的核心点、噪声点和边界点

使用 DBSCAN 的主要优势之一是,它不假设聚类像 k-means 中的球形那样。此外,DBSCAN 与 k-means 和层次聚类不同之处在于,它不一定将每个点分配到一个簇中,但能够移除噪声点。

为了更具说明性的例子,让我们创建一个新的半月形结构数据集,以比较 k-means 聚类、层次聚类和 DBSCAN:

>>> from sklearn.datasets import make_moons
>>> X, y = make_moons(n_samples=200,
...                   noise=0.05,
...                   random_state=0)
>>> plt.scatter(X[:, 0], X[:, 1])
>>> plt.xlabel('Feature 1')
>>> plt.ylabel('Feature 2')
>>> plt.tight_layout()
>>> plt.show() 

如图所示的结果绘图中,可以看到两个明显的半月形簇,每个簇包含 100 个示例(数据点):

图表,散点图  自动生成的描述

图 10.14:一个双特征半月形数据集

我们将首先使用 k-means 算法和完全链接聚类来查看这些先前讨论的聚类算法是否能够成功识别半月形状作为单独的簇。代码如下:

>>> f, (ax1, ax2) = plt.subplots(1, 2, figsize=(8, 3))
>>> km = KMeans(n_clusters=2,
...             random_state=0)
>>> y_km = km.fit_predict(X)
>>> ax1.scatter(X[y_km == 0, 0],
...             X[y_km == 0, 1],
...             c='lightblue',
...             edgecolor='black',
...             marker='o',
...             s=40,
...             label='cluster 1')
>>> ax1.scatter(X[y_km == 1, 0],
...             X[y_km == 1, 1],
...             c='red',
...             edgecolor='black',
...             marker='s',
...             s=40,
...             label='cluster 2')
>>> ax1.set_title('K-means clustering')
>>> ax1.set_xlabel('Feature 1')
>>> ax1.set_ylabel('Feature 2')
>>> ac = AgglomerativeClustering(n_clusters=2,
...                              affinity='euclidean',
...                              linkage='complete')
>>> y_ac = ac.fit_predict(X)
>>> ax2.scatter(X[y_ac == 0, 0],
...             X[y_ac == 0, 1],
...             c='lightblue',
...             edgecolor='black',
...             marker='o',
...             s=40,
...             label='Cluster 1')
>>> ax2.scatter(X[y_ac == 1, 0],
...             X[y_ac == 1, 1],
...             c='red',
...             edgecolor='black',
...             marker='s',
...             s=40,
...             label='Cluster 2')
>>> ax2.set_title('Agglomerative clustering')
>>> ax2.set_xlabel('Feature 1')
>>> ax2.set_ylabel('Feature 2')
>>> plt.legend()
>>> plt.tight_layout()
>>> plt.show() 

根据可视化的聚类结果,我们可以看到 k-means 算法无法分离两个簇,而且层次聚类算法在面对这些复杂形状时也面临挑战:

图,箭头  由系统自动生成描述

图 10.15:半月形数据集上的 k-means 和聚合聚类

最后,让我们尝试在这个数据集上应用 DBSCAN 算法,看看它是否能够通过密度方法找到两个半月形簇:

>>> from sklearn.cluster import DBSCAN
>>> db = DBSCAN(eps=0.2,
...             min_samples=5,
...             metric='euclidean')
>>> y_db = db.fit_predict(X)
>>> plt.scatter(X[y_db == 0, 0],
...             X[y_db == 0, 1],
...             c='lightblue',
...             edgecolor='black',
...             marker='o',
...             s=40,
...             label='Cluster 1')
>>> plt.scatter(X[y_db == 1, 0],
...             X[y_db == 1, 1],
...             c='red',
...             edgecolor='black',
...             marker='s',
...             s=40,
...             label='Cluster 2')
>>> plt.xlabel('Feature 1')
>>> plt.ylabel('Feature 2')
>>> plt.legend()
>>> plt.tight_layout()
>>> plt.show() 

DBSCAN 算法可以成功检测半月形状,这突显了 DBSCAN 的一个优势——可以对任意形状的数据进行聚类:

图,散点图  由系统自动生成描述

图 10.16:半月形数据集上的 DBSCAN 聚类

然而,我们也应该注意一些 DBSCAN 的缺点。随着数据集中特征数量的增加——假设训练示例数量固定——维度灾难的负面影响增加。如果我们使用欧氏距离度量尤其是问题。然而,维度灾难问题并不局限于 DBSCAN:它也影响其他使用欧氏距离度量的聚类算法,例如 k-means 和层次聚类算法。此外,DBSCAN 中有两个超参数(MinPts 和!)需要优化以产生良好的聚类结果。如果数据集中的密度差异相对较大,找到 MinPts 和!的良好组合可能会成为一个问题。

基于图的聚类

到目前为止,我们已经看到了三种最基本的聚类算法类别:基于原型的 k-means 聚类,聚合层次聚类和基于密度的 DBSCAN 聚类。然而,在本章中我们还没有涉及的是第四类更高级的聚类算法:基于图的聚类。可能是基于图的聚类家族中最显著的成员是光谱聚类算法。

尽管光谱聚类有许多不同的实现,它们的共同之处在于利用相似性或距离矩阵的特征向量来推导聚类关系。由于光谱聚类超出了本书的范围,您可以阅读乌尔里克·冯·吕克斯堡(Ulrike von Luxburg)的优秀教程以了解更多关于这个主题的内容(光谱聚类教程统计与计算,17(4):395-416,2007)。它可以在 arXiv 上免费获取:arxiv.org/pdf/0711.0189v1.pdf

注意,在实践中,不同聚类算法在给定数据集上表现最佳并不总是显而易见,特别是如果数据以多个维度呈现,这会使得可视化变得困难或不可能。此外,强调成功的聚类不仅取决于算法及其超参数;选择合适的距离度量标准以及使用能够帮助指导实验设置的领域知识可能更为重要。

在维度诅咒的背景下,因此在执行聚类之前常见的做法是应用降维技术。这些面向无监督数据集的降维技术包括主成分分析和 t-SNE,我们在第五章《通过降维压缩数据》中涵盖了这些技术。此外,将数据集压缩到二维子空间特别常见,这样可以使用二维散点图可视化聚类和分配的标签,这对于评估结果特别有帮助。

总结

在这一章中,您学习了三种不同的聚类算法,这些算法可以帮助我们发现数据中隐藏的结构或信息。我们从基于原型的方法开始,即 k-means 算法,该算法根据指定数量的聚类中心将示例聚类成球形。由于聚类是一种无监督方法,我们没有地面真实标签来评估模型的性能。因此,我们使用了内在性能度量标准,例如肘部法则或轮廓分析,试图量化聚类的质量。

然后,我们看了一种不同的聚类方法:凝聚层次聚类。层次聚类不需要预先指定聚类数,其结果可以在树形图表示中可视化,这有助于解释结果。我们在本章中介绍的最后一个聚类算法是 DBSCAN,这是一种根据局部密度分组点的算法,能够处理异常值并识别非球形形状。

在这次涉足无监督学习领域之后,现在是介绍一些最激动人心的监督学习机器学习算法的时候了:多层人工神经网络。随着它们最近的复苏,神经网络再次成为机器学习研究中最热门的话题。由于最近开发的深度学习算法,神经网络被认为是许多复杂任务的最先进技术,如图像分类、自然语言处理和语音识别。在第十一章从头开始实现多层人工神经网络中,我们将构建自己的多层神经网络。在第十二章使用 PyTorch 并行化神经网络训练中,我们将使用 PyTorch 库,该库专门用于利用图形处理单元高效训练多层神经网络模型。

加入我们书籍的 Discord 空间

加入本书的 Discord 工作空间,参与每月的问答活动与作者交流:

packt.link/MLwPyTorch

第十一章:从头开始实现多层人工神经网络

你可能已经知道,深度学习在媒体上受到了很多关注,毫无疑问,它是机器学习领域最热门的话题。深度学习可以理解为机器学习的一个子领域,其关注点是如何高效地训练具有多层的人工神经网络NNs)。在本章中,你将学习人工神经网络的基本概念,以便在接下来的章节中,我们将介绍专门用于图像和文本分析的基于 Python 的高级深度学习库和深度神经网络DNN)架构。

本章将涵盖的主题如下:

  • 对多层神经网络(Multilayer NNs)的概念性理解

  • 从头开始实现神经网络训练的基本反向传播算法

  • 为图像分类训练基本的多层神经网络

用人工神经网络对复杂函数建模

在本书的开头,我们从人工神经元开始了机器学习算法的旅程,这在《第二章》,《训练简单的分类机器学习算法》中有所介绍。人工神经元代表了我们将在本章讨论的多层人工神经网络的构建模块。

人工神经网络的基本概念建立在关于人脑如何解决复杂问题任务的假设和模型之上。尽管人工神经网络近年来越来越受欢迎,但早期对神经网络的研究可以追溯到 1940 年代,当时 Warren McCulloch 和 Walter Pitts 首次描述了神经元的工作方式,《神经活动中所含的思想的逻辑演算》,作者为 W. S. McCulloch 和 W. Pitts,发表于《数理生物物理学公报》(The Bulletin of Mathematical Biophysics),5(4):115–133,1943 年。

然而,在第一个麦卡洛克-皮茨神经元模型——罗森布拉特在 1950 年代提出的感知器——实现后的几十年间,许多研究人员和机器学习从业者逐渐失去了对神经网络的兴趣,因为没有人能有效地训练具有多层的神经网络。直到 1986 年,D.E. Rumelhart、G.E. Hinton 和 R.J. Williams 参与了反向传播算法的(重新)发现和推广,有效地训练神经网络,这一算法将在本章后面更详细地讨论《通过反向传播错误学习表示》,作者为 D.E. Rumelhart、G.E. Hinton 和 R.J. Williams,发表于《自然》(Nature),323(6088):533–536,1986 年。对人工智能AI)、机器学习和神经网络历史感兴趣的读者也建议阅读所谓的AI 寒冬的维基百科文章,这些是研究社区失去对神经网络研究兴趣的时期(en.wikipedia.org/wiki/AI_winter)。

然而,如今神经网络(NNs)比以往任何时候都更受欢迎,这要归功于上一个十年取得的许多突破,这导致了我们现在所称的深度学习算法和架构——由许多层组成的 NNs。NNs 不仅在学术研究中是热门话题,而且在大型技术公司(如 Facebook、Microsoft、Amazon、Uber、Google 等)中也是如此,这些公司在人工神经网络和深度学习研究上投入了大量资源。

到目前为止,由深度学习算法驱动的复杂神经网络被认为是解决诸如图像和语音识别等复杂问题的最先进解决方案。一些最近的应用包括:

单层神经网络概述

本章讨论的是多层神经网络,它们的工作原理以及如何训练它们来解决复杂问题。然而,在深入研究特定的多层神经网络架构之前,让我们简要重申我们在 第二章 中介绍的单层神经网络概念,即 ADAptive LInear NEuron(Adaline)算法,如 图 11.1 所示:

自动生成的图表说明

图 11.1:Adaline 算法

第二章 中,我们实现了 Adaline 算法来进行二元分类,并使用梯度下降优化算法来学习模型的权重系数。在每个 epoch(训练数据集的一次遍历)中,我们使用以下更新规则更新权重向量 w 和偏置单元 b

其中 分别代表偏置单元和权重向量 w 中的每个权重 w[j]。

换句话说,我们基于整个训练数据集计算梯度,并通过在损失梯度的反方向上迈出一步来更新模型的权重 。(为简单起见,我们将专注于权重并在以下段落中省略偏置单元;然而,正如你从 第二章 中记得的那样,相同的概念也适用。)为了找到模型的最优权重,我们优化了一个我们定义为均方误差MSE)损失函数 L(w) 的目标函数。此外,我们将梯度乘以一个因子,学习率 ,我们需要仔细选择以在学习速度与超过损失函数全局最小值的风险之间取得平衡。

在梯度下降优化中,我们在每个迭代后同时更新所有权重,并且我们定义了权重向量中每个权重 w[j] 的偏导数,w,如下所示:

在这里,y^(^i^) 是特定样本 x^(^i^) 的目标类标签,a^(^i^) 是神经元的激活,对于 Adaline 的特殊情况,它是一个线性函数。

此外,我们如下定义了激活函数

这里,净输入 z 是连接输入层与输出层的权重的线性组合:

当我们使用激活函数 来计算梯度更新时,我们实现了一个阈值函数,将连续值输出压缩为用于预测的二进制类标签:

单层命名约定

注意,尽管 Adaline 由两层组成,即一个输入层和一个输出层,但由于其输入层和输出层之间的单一连接,它被称为单层网络。

另外,我们了解了一种加速模型学习的特定技巧,即所谓的随机梯度下降SGD)优化。 SGD 从单个训练样本(在线学习)或一小部分训练示例(小批量学习)中近似损失。 在本章后面,当我们实现和训练多层感知机MLP)时,我们将使用这个概念。 除了由于梯度下降比梯度下降更频繁地更新权重导致更快的学习之外,其嘈杂的本质在训练具有非线性激活函数的多层神经网络时也被认为是有益的。 这里,添加的噪声可以帮助逃离局部损失最小值,但我们将在本章后面更详细地讨论这个主题。

引入多层神经网络架构

在本节中,您将学习如何将多个单神经元连接到多层前馈神经网络;这种特殊类型的全连接网络也称为MLP

图 11.2说明了由两层组成的 MLP 的概念:

图表,工程图纸  描述自动生成

图 11.2: 一个两层 MLP

除了数据输入之外,图 11.2中描述的 MLP 具有一个隐藏层和一个输出层。隐藏层中的单元与输入特征完全连接,输出层与隐藏层完全连接。如果这样的网络有多个隐藏层,我们也称其为深度神经网络。(请注意,在某些情况下,输入也被视为一层。然而,在这种情况下,将 Adaline 模型,即单层神经网络,视为两层神经网络可能会令人困惑。)

添加额外的隐藏层

我们可以在 MLP 中添加任意数量的隐藏层,以创建更深的网络结构。实际上,我们可以将 NN 中的层数和单位数视为额外的超参数,我们希望使用交叉验证技术为给定的问题任务进行优化,这些内容我们在第六章中讨论了学习模型评估和超参数调整的最佳实践

然而,随着网络添加更多层,用于更新网络参数的损失梯度(稍后我们将通过反向传播计算)将变得越来越小。这种梯度消失问题使得模型学习更具挑战性。因此,已经开发了特殊算法来帮助训练这种 DNN 结构;这就是深度学习,我们将在接下来的章节中更详细地讨论。

图 11.2所示,我们将第l层中的第i个激活单元表示为 。为了使数学和代码实现更直观,我们将不使用数字索引来引用层,而是使用in上标表示输入特征,h上标表示隐藏层,out上标表示输出层。例如, 表示第i个输入特征值, 表示隐藏层中的第i个单元, 表示输出层中的第i个单元。请注意,图 11.2中的b代表偏置单元。事实上,b^(^h^)和b^(^(out)^)是具有与其对应层中节点数相等的元素数量的向量。例如,b^(^h^)存储d个偏置单元,其中d是隐藏层中的节点数。如果这听起来令人困惑,不用担心。稍后查看代码实现,我们初始化权重矩阵和偏置单元向量将有助于澄清这些概念。

每个 l 层中的节点通过权重系数与 l + 1 层中的所有节点相连。例如,层 l 中第 k 个单元到层 l + 1 中第 j 个单元的连接将被写为 。回顾 图 11.2,我们将连接输入到隐藏层的权重矩阵称为 W^(^h^),并将连接隐藏层到输出层的矩阵称为 W^(^(out)^)。

虽然输出层的一个单元足以完成二元分类任务,但在前述图中我们看到了更一般的神经网络形式,它允许我们通过一对所有OvA)技术的泛化来进行多类别分类。为了更好地理解其工作原理,请记住我们在第四章 构建良好的训练数据集 – 数据预处理 中介绍的分类变量的独热编码表示。

例如,我们可以将经典的鸢尾花数据集中的三类标签(0=山鸢尾,1=变色鸢尾,2=维吉尼亚鸢尾)进行如下编码:

这种独热向量表示使我们能够处理训练数据集中任意数量的独特类标签的分类任务。

如果你对神经网络表示还不熟悉,索引符号(下标和上标)可能一开始看起来有点令人困惑。但是,在后面的章节中,当我们对神经网络表示进行向量化时,这些看似过于复杂的内容将会变得更加合理。正如之前介绍的那样,我们通过一个 d×m 维度的矩阵 W^(^h^) 来总结连接输入和隐藏层的权重,其中 d 是隐藏单元的数量,m 是输入单元的数量。

通过前向传播激活神经网络

在本节中,我们将描述前向传播的过程,以计算 MLP 模型的输出。为了理解它如何融入到学习 MLP 模型的背景中,让我们简要总结 MLP 学习过程的三个简单步骤:

  1. 从输入层开始,我们通过网络将训练数据的模式进行前向传播,生成一个输出。

  2. 根据网络的输出,我们使用稍后将描述的损失函数计算我们希望最小化的损失。

  3. 我们通过反向传播损失,找到其对网络中每个权重和偏置单元的导数,并更新模型。

最后,在我们对多个时期重复执行这三个步骤并学习 MLP 的权重和偏置参数之后,我们使用前向传播来计算网络输出,并应用阈值函数以获得在独热表示中的预测类标签,这是我们在前一节中描述过的。

现在,让我们逐步进行前向传播的各个步骤,从训练数据模式中生成输出。由于隐藏层中的每个单元都与输入层中的所有单元连接,我们首先计算隐藏层激活单元 如下所示:

图 说明

在这里, 是净输入, 是激活函数,必须是可微的,以便使用基于梯度的方法学习连接神经元的权重。为了能够解决复杂问题,如图像分类,我们在 MLP 模型中需要非线性激活函数,例如我们在第三章“使用 Scikit-Learn 的机器学习分类器之旅”中记得的 sigmoid(logistic)激活函数:

图 说明

正如您可能记得的那样,Sigmoid 函数是一条S形曲线,将净输入 z 映射到 0 到 1 的 logistic 分布范围内,在 Figure 11.3 中显示 y 轴在 z = 0 处切割:

图 说明:自动低置信度生成

图 11.3:Sigmoid 激活函数

MLP 是前馈人工神经网络的典型例子。术语 feedforward 指的是每一层作为下一层的输入,没有循环,与递归神经网络形成对比——这是我们将在本章稍后讨论的架构,并在第十五章“使用递归神经网络建模顺序数据”中进行更详细的讨论。术语 multilayer perceptron 可能听起来有点混淆,因为这种网络架构中的人工神经元通常是 sigmoid 单元,而不是感知器。我们可以将 MLP 中的神经元视为 logistic 回归单元,返回在 0 到 1 的连续范围内的值。

为了代码的效率和可读性,我们将使用基本线性代数的概念,通过 NumPy 将激活写成更紧凑的形式,而不是编写多个嵌套和计算昂贵的 Python for 循环。

图 说明

这里,z^(^h^) 是我们的 1×m 维特征向量。W^(^h^) 是一个 d×m 维权重矩阵,其中 d 是隐藏层中的单元数;因此,转置矩阵 W^(^h^)^T 是 m×d 维的。偏置向量 b^(^h^) 包含 d 个偏置单元(每个隐藏节点一个偏置单元)。

在矩阵-向量乘法之后,我们得到 1×d 维的净输入向量 z^(^h^),用于计算激活 a^(^h^)(其中 )。

此外,我们可以将这一计算推广到训练数据集中的所有 n 个示例:

Z^(^h^) = X^(^(in)^)W^(^h^)^T + b^(^h^)

在这里,X^(^(in)^)现在是一个n×m矩阵,矩阵乘法将得到一个n×d维度的净输入矩阵Z^(^h^)。最后,我们对净输入矩阵中的每个值应用激活函数 ,以获得下一层(这里是输出层)的n×d激活矩阵:

同样地,我们可以为多个示例的输出层激活以向量化形式编写:

Z^(^(out)^) = A^(^h^)W^(^(out)^)^T + b^(^(out)^)

在这里,我们将t×d矩阵W^(^(out)^)的转置(t是输出单元的数量)乘以n×d维度矩阵A^(^h^),并加上t维度偏置向量b^(^(out)^),以获得n×t维度矩阵Z^(^(out)^)(该矩阵中的列表示每个样本的输出)。

最后,我们应用 sigmoid 激活函数来获得网络的连续值输出:

类似于Z^(^(out)^),A^(^(out)^)是一个n×t维度的矩阵。

分类手写数字

在前一节中,我们涵盖了关于 NN 的大量理论,如果您对此主题还不熟悉,可能会有点压倒性。在我们继续讨论 MLP 模型学习权重算法——反向传播之前,让我们从理论中稍作休息,看看 NN 的实际应用。

反向传播的额外资源

NN 理论可能非常复杂;因此,我们希望为读者提供更详细或不同视角覆盖本章讨论主题的其他资源:

在本节中,我们将实现并训练我们的第一个多层 NN 来分类来自流行的混合国家标准技术研究所MNIST)数据集的手写数字,该数据集由 Yann LeCun 和其他人构建,并作为机器学习算法的流行基准数据集(基于梯度的学习应用于文档识别,由Y. LeCunL. BottouY. BengioP. Haffner著,IEEE 会议论文集,86(11): 2278-2324,1998 年)。

获取和准备 MNIST 数据集

MNIST 数据集公开可用于 yann.lecun.com/exdb/mnist/,包括以下四个部分:

  1. 训练数据集图片train-images-idx3-ubyte.gz(9.9 MB,解压后 47 MB,共 60,000 个示例)

  2. 训练数据集标签train-labels-idx1-ubyte.gz(29 KB,解压后 60 KB,共 60,000 个标签)

  3. 测试数据集图片t10k-images-idx3-ubyte.gz(1.6 MB,解压后 7.8 MB,共 10,000 个示例)

  4. 测试数据集标签t10k-labels-idx1-ubyte.gz(5 KB,解压后 10 KB,共 10,000 个标签)

MNIST 数据集由美国国家标准与技术研究院NIST)的两个数据集构成。训练数据集包括来自 250 个不同人的手写数字,其中 50% 是高中学生,另外 50% 是人口普查局的员工。请注意,测试数据集包含了不同人群的手写数字,遵循相同的拆分。

我们不需要自己下载上述数据集文件并将它们预处理为 NumPy 数组,而是可以使用 scikit-learn 的新fetch_openml函数更方便地加载 MNIST 数据集:

>>> from sklearn.datasets import fetch_openml
>>> X, y = fetch_openml('mnist_784', version=1,
...                     return_X_y=True)
>>> X = X.values
>>> y = y.astype(int).values 

在 scikit-learn 中,fetch_openml 函数从 OpenML (www.openml.org/d/554) 下载 MNIST 数据集作为 pandas 的 DataFrame 和 Series 对象,因此我们使用 .values 属性来获取底层的 NumPy 数组。(如果你使用的是 scikit-learn 版本低于 1.0,fetch_openml 直接下载 NumPy 数组,因此可以省略使用 .values 属性。)X 数组的 n×m 维度由 70,000 张图片组成,每张图片有 784 个像素,y 数组存储了对应的 70,000 个类别标签,我们可以通过检查数组的维度来确认:

>>> print(X.shape)
(70000, 784)
>>> print(y.shape)
(70000,) 

MNIST 数据集中的图像由 28×28 像素组成,每个像素由灰度强度值表示。在这里,fetch_openml 已经将 28×28 像素展开为一维行向量,这些向量表示我们 X 数组中的行(每行或每张图像有 784 个像素)。fetch_openml 函数返回的第二个数组 y 包含手写数字的相应目标变量,即类别标签(整数 0-9)。

接下来,让我们通过以下代码行将 MNIST 中的像素值归一化到范围 -1 到 1(原始范围为 0 到 255):

>>> X = ((X / 255.) - .5) * 2 

这样做的原因是在这些条件下,基于梯度的优化更加稳定,正如 第二章 中所讨论的。请注意,我们是基于像素的缩放,这与我们在前几章中采取的特征缩放方法不同。

我们之前从训练数据集中推导出了缩放参数,并将其用于缩放训练数据集和测试数据集中的每一列。然而,当处理图像像素时,通常将它们居中在零点并重新缩放到 [-1, 1] 范围内,这也是常见且通常能很好地工作。

要了解 MNIST 中这些图像的样子,让我们通过 Matplotlib 的imshow函数将我们特征矩阵中的 784 像素向量重塑为原始的 28×28 图像,并进行可视化:

>>> import matplotlib.pyplot as plt
>>> fig, ax = plt.subplots(nrows=2, ncols=5,
...                        sharex=True, sharey=True)
>>> ax = ax.flatten()
>>> for i in range(10):
...     img = X[y == i][0].reshape(28, 28)
...     ax[i].imshow(img, cmap='Greys')
>>> ax[0].set_xticks([])
>>> ax[0].set_yticks([])
>>> plt.tight_layout()
>>> plt.show() 

现在我们应该看到一个由 2×5 个子图组成的图,显示每个唯一数字的代表性图像:

包含图形用户界面的图片,自动生成的描述

图 11.4:显示每个类别中随机选择的一个手写数字的图

此外,让我们也绘制同一数字的多个示例,以查看每个数字的手写风格有多不同:

>>> fig, ax = plt.subplots(nrows=5,
...                        ncols=5,
...                        sharex=True,
...                        sharey=True)
>>> ax = ax.flatten()
>>> for i in range(25):
...     img = X[y == 7][i].reshape(28, 28)
...     ax[i].imshow(img, cmap='Greys')
>>> ax[0].set_xticks([])
>>> ax[0].set_yticks([])
>>> plt.tight_layout()
>>> plt.show() 

执行完代码后,我们现在应该看到数字 7 的前 25 个变体:

包含日历的图片,自动生成的描述

图 11.5:手写数字 7 的不同变体

最后,让我们将数据集分为训练、验证和测试子集。以下代码将分割数据集,使得 55,000 张图像用于训练,5,000 张图像用于验证,以及 10,000 张图像用于测试:

>>> from sklearn.model_selection import train_test_split
>>> X_temp, X_test, y_temp, y_test = train_test_split(
...     X, y, test_size=10000, random_state=123, stratify=y
... )
>>> X_train, X_valid, y_train, y_valid = train_test_split(
...     X_temp, y_temp, test_size=5000,
...     random_state=123, stratify=y_temp
... ) 

实现多层感知器

在本小节中,我们现在将从头开始实现一个 MLP 来对 MNIST 数据集中的图像进行分类。为了保持简单,我们将只实现一个只有一个隐藏层的 MLP。由于这种方法一开始可能看起来有点复杂,建议你从 Packt Publishing 的网站或 GitHub (github.com/rasbt/machine-learning-book)下载本章的示例代码,以便查看带有注释和语法高亮的 MLP 实现,以提高可读性。

如果你没有从附带的 Jupyter Notebook 文件运行代码,或者无法访问互联网,可以将本章中的NeuralNetMLP代码复制到你当前工作目录下的 Python 脚本文件中(例如neuralnet.py),然后通过以下命令将其导入到当前的 Python 会话中:

from neuralnet import NeuralNetMLP 

代码将包含一些我们尚未讨论的部分,例如反向传播算法。如果代码中的某些部分目前对你来说并不完全理解,不必担心;我们稍后会对某些部分进行跟进。然而,在这个阶段检查代码可以使后续的理论更容易理解。

因此,让我们来看下面的多层感知器的实现,从计算逻辑 sigmoid 激活和将整数类标签数组转换为独热编码标签的两个辅助函数开始:

import numpy as np
def sigmoid(z):
    return 1\. / (1\. + np.exp(-z))
def int_to_onehot(y, num_labels):
    ary = np.zeros((y.shape[0], num_labels))
    for i, val in enumerate(y):
        ary[i, val] = 1
    return ary 

下面,我们实现了我们的多层感知器的主类,我们称之为NeuralNetMLP。有三个类方法,. __init__(), .forward(), 和 .backward(),我们将逐一讨论,从__init__构造函数开始:

class NeuralNetMLP:
    def __init__(self, num_features, num_hidden,
                 num_classes, random_seed=123):
        super().__init__()

        self.num_classes = num_classes

        # hidden
        rng = np.random.RandomState(random_seed)

        self.weight_h = rng.normal(
            loc=0.0, scale=0.1, size=(num_hidden, num_features))
        self.bias_h = np.zeros(num_hidden)

        # output
        self.weight_out = rng.normal(
            loc=0.0, scale=0.1, size=(num_classes, num_hidden))
        self.bias_out = np.zeros(num_classes) 

__init__ 构造函数实例化了隐藏层和输出层的权重矩阵和偏置向量。接下来,让我们看看这些如何在 forward 方法中用于进行预测:

 def forward(self, x):
        # Hidden layer

        # input dim: [n_hidden, n_features]
        #        dot [n_features, n_examples] .T
        # output dim: [n_examples, n_hidden]
        z_h = np.dot(x, self.weight_h.T) + self.bias_h
        a_h = sigmoid(z_h)
        # Output layer
        # input dim: [n_classes, n_hidden]
        #        dot [n_hidden, n_examples] .T
        # output dim: [n_examples, n_classes]
        z_out = np.dot(a_h, self.weight_out.T) + self.bias_out
        a_out = sigmoid(z_out)
        return a_h, a_out 

forward 方法接收一个或多个训练样本,并返回预测结果。实际上,它同时返回隐藏层和输出层的激活值,a_ha_out。而 a_out 表示类成员概率,我们可以将其转换为类标签,这是我们关心的内容,同时我们还需要隐藏层的激活值 a_h 来优化模型参数,即隐藏层和输出层的权重和偏置单元。

最后,让我们谈谈 backward 方法,它更新神经网络的权重和偏置参数:

 def backward(self, x, a_h, a_out, y):

        #########################
        ### Output layer weights
        #########################

        # one-hot encoding
        y_onehot = int_to_onehot(y, self.num_classes)
        # Part 1: dLoss/dOutWeights
        ## = dLoss/dOutAct * dOutAct/dOutNet * dOutNet/dOutWeight
        ## where DeltaOut = dLoss/dOutAct * dOutAct/dOutNet
        ## for convenient re-use

        # input/output dim: [n_examples, n_classes]
        d_loss__d_a_out = 2.*(a_out - y_onehot) / y.shape[0]
        # input/output dim: [n_examples, n_classes]
        d_a_out__d_z_out = a_out * (1\. - a_out) # sigmoid derivative
        # output dim: [n_examples, n_classes]
        delta_out = d_loss__d_a_out * d_a_out__d_z_out
        # gradient for output weights

        # [n_examples, n_hidden]
        d_z_out__dw_out = a_h

        # input dim: [n_classes, n_examples]
        #           dot [n_examples, n_hidden]
        # output dim: [n_classes, n_hidden]
        d_loss__dw_out = np.dot(delta_out.T, d_z_out__dw_out)
        d_loss__db_out = np.sum(delta_out, axis=0)

        #################################
        # Part 2: dLoss/dHiddenWeights
        ## = DeltaOut * dOutNet/dHiddenAct * dHiddenAct/dHiddenNet
        #    * dHiddenNet/dWeight

        # [n_classes, n_hidden]
        d_z_out__a_h = self.weight_out

        # output dim: [n_examples, n_hidden]
        d_loss__a_h = np.dot(delta_out, d_z_out__a_h)

        # [n_examples, n_hidden]
        d_a_h__d_z_h = a_h * (1\. - a_h) # sigmoid derivative

        # [n_examples, n_features]
        d_z_h__d_w_h = x

        # output dim: [n_hidden, n_features]
        d_loss__d_w_h = np.dot((d_loss__a_h * d_a_h__d_z_h).T,
                                d_z_h__d_w_h)
        d_loss__d_b_h = np.sum((d_loss__a_h * d_a_h__d_z_h), axis=0)
        return (d_loss__dw_out, d_loss__db_out,
                d_loss__d_w_h, d_loss__d_b_h) 

backward 方法实现了所谓的反向传播算法,计算损失相对于权重和偏置参数的梯度。与 Adaline 类似,这些梯度然后用于通过梯度下降更新这些参数。请注意,多层神经网络比它们的单层兄弟更复杂,我们将在后面的部分讨论代码后讨论如何计算梯度的数学概念。现在,只需将 backward 方法视为计算梯度以用于梯度下降更新的一种方法。为简单起见,此推导基于的损失函数与 Adaline 中使用的相同的 MSE 损失函数相同。在后续章节中,我们将看到替代损失函数,例如多类别交叉熵损失,它是二元逻辑回归损失向多个类别的泛化。

查看 NeuralNetMLP 类的此代码实现,您可能已经注意到,这种面向对象的实现与围绕 .fit().predict() 方法为中心的熟悉 scikit-learn API 有所不同。相反,NeuralNetMLP 类的主要方法是 .forward().backward() 方法。其背后的一个原因是,这样做可以使复杂的神经网络在信息流通过网络方面更容易理解一些。

另一个原因是,这种实现与诸如 PyTorch 等更高级深度学习库的运行方式相对类似,我们将在接下来的章节中介绍并使用这些库来实现更复杂的神经网络。

在我们实现了 NeuralNetMLP 类之后,我们使用以下代码来实例化一个新的 NeuralNetMLP 对象:

>>> model = NeuralNetMLP(num_features=28*28,
...                      num_hidden=50,
...                      num_classes=10) 

model接受将 MNIST 图像重塑为 784 维向量(格式为X_trainX_validX_test,我们之前定义过)的输入,用于 10 个整数类(数字 0-9)。隐藏层由 50 个节点组成。另外,如您可以从先前定义的.forward()方法中看到的那样,我们在第一个隐藏层和输出层之后使用了 sigmoid 激活函数,以保持简单。在后面的章节中,我们将学习关于隐藏层和输出层的替代激活函数。

图 11.6 总结了我们上面实例化的神经网络架构:

自动生成的图表描述

图 11.6:用于标记手写数字的 NN 架构

在下一小节中,我们将实现训练函数,通过反向传播在小批量数据上训练网络。

编写神经网络训练循环

现在我们已经在前一小节中实现了NeuralNetMLP类并初始化了一个模型,下一步是训练模型。我们将分步骤完成此过程。首先,我们将为数据加载定义一些辅助函数。其次,我们将这些函数嵌入到遍历多个时期的训练循环中。

我们要定义的第一个函数是小批量生成器,它接受我们的数据集并将其分成用于随机梯度下降训练的所需大小的小批量。代码如下:

>>> import numpy as np
>>> num_epochs = 50
>>> minibatch_size = 100
>>> def minibatch_generator(X, y, minibatch_size):
...     indices = np.arange(X.shape[0])
...     np.random.shuffle(indices)
...     for start_idx in range(0, indices.shape[0] - minibatch_size
...                            + 1, minibatch_size):
...         batch_idx = indices[start_idx:start_idx + minibatch_size]
...         yield X[batch_idx], y[batch_idx] 

在我们继续下一个函数之前,让我们确认小批量生成器按预期工作,并生成所需大小的小批量。以下代码将尝试遍历数据集,然后我们将打印小批量的维度。请注意,在以下代码示例中,我们将删除break语句。代码如下:

>>> # iterate over training epochs
>>> for i in range(num_epochs):
...     # iterate over minibatches
...     minibatch_gen = minibatch_generator(
...         X_train, y_train, minibatch_size)
...     for X_train_mini, y_train_mini in minibatch_gen:
...         break
...     break
>>> print(X_train_mini.shape)
(100, 784)
>>> print(y_train_mini.shape)
(100,) 

正如我们所看到的,网络按预期返回大小为 100 的小批量。

接下来,我们必须定义损失函数和性能度量,以便监控训练过程并评估模型。可以实现 MSE 损失和准确率函数如下:

>>> def mse_loss(targets, probas, num_labels=10):
...     onehot_targets = int_to_onehot(
...         targets, num_labels=num_labels
...     )
...     return np.mean((onehot_targets - probas)**2)
>>> def accuracy(targets, predicted_labels):
...     return np.mean(predicted_labels == targets) 

让我们测试前述函数并计算我们在上一节中实例化的模型的初始验证集 MSE 和准确率:

>>> _, probas = model.forward(X_valid)
>>> mse = mse_loss(y_valid, probas)
>>> print(f'Initial validation MSE: {mse:.1f}')
Initial validation MSE: 0.3
>>> predicted_labels = np.argmax(probas, axis=1)
>>> acc = accuracy(y_valid, predicted_labels)
>>> print(f'Initial validation accuracy: {acc*100:.1f}%')
Initial validation accuracy: 9.4% 

在此代码示例中,请注意model.forward()返回隐藏层和输出层的激活。请记住,我们有 10 个输出节点(每个对应一个唯一的类标签)。因此,在计算 MSE 时,我们首先在mse_loss()函数中将类标签转换为独热编码的类标签。在实践中,首先对平方差矩阵的行或列求平均值没有区别,因此我们只需调用np.mean()而不指定任何轴,这样它将返回一个标量。

由于我们使用了逻辑 sigmoid 函数,输出层的激活值处于 [0, 1] 范围内。对于每个输入,输出层产生的是在 [0, 1] 范围内的 10 个值,因此我们使用了 np.argmax() 函数来选择最大值的索引位置,这个索引位置即预测的类别标签。然后,我们将真实标签与预测的类别标签进行比较,通过我们定义的 accuracy() 函数来计算准确率。从前面的输出可以看出,准确率并不是很高。然而,考虑到我们有一个包含 10 个类别的平衡数据集,一个未经训练的模型产生随机预测的情况下,大约 10% 的预测准确率是可以预期的。

使用前面的代码,例如,如果我们将 y_train 提供为目标的输入,并将模型输入 X_train 进行预测,我们可以计算整个训练集的性能。然而,在实践中,由于计算机内存通常限制了模型一次正向传递可以接收多少数据(由于大矩阵乘法),因此我们根据我们之前的小批量生成器定义了我们的 MSE 和准确率计算。以下函数将逐个小批量地迭代整个数据集来更加高效地使用内存计算 MSE 和准确率:

>>> def compute_mse_and_acc(nnet, X, y, num_labels=10,
...                         minibatch_size=100):
...     mse, correct_pred, num_examples = 0., 0, 0
...     minibatch_gen = minibatch_generator(X, y, minibatch_size)
...     for i, (features, targets) in enumerate(minibatch_gen):
...         _, probas = nnet.forward(features)
...         predicted_labels = np.argmax(probas, axis=1)
...         onehot_targets = int_to_onehot(
...             targets, num_labels=num_labels
...         )
...         loss = np.mean((onehot_targets - probas)**2)
...         correct_pred += (predicted_labels == targets).sum()
...         num_examples += targets.shape[0]
...         mse += loss
...     mse = mse/i
...     acc = correct_pred/num_examples
...     return mse, acc 

在我们实现训练循环之前,让我们测试这个函数,并计算前面部分中实例化的模型的初始训练集均方误差(MSE)和准确率,并确保其按预期工作:

>>> mse, acc = compute_mse_and_acc(model, X_valid, y_valid)
>>> print(f'Initial valid MSE: {mse:.1f}')
Initial valid MSE: 0.3
>>> print(f'Initial valid accuracy: {acc*100:.1f}%')
Initial valid accuracy: 9.4% 

从结果中可以看出,我们的生成器方法产生了与先前定义的 MSE 和准确率函数相同的结果,除了 MSE 中的小舍入误差(0.27 对比 0.28),对我们的目的来说可以忽略不计。

现在让我们来到主要部分,实现训练我们的模型的代码:

>>> def train(model, X_train, y_train, X_valid, y_valid, num_epochs,
...           learning_rate=0.1):
...     epoch_loss = []
...     epoch_train_acc = []
...     epoch_valid_acc = []
...
...     for e in range(num_epochs):
...         # iterate over minibatches
...         minibatch_gen = minibatch_generator(
...             X_train, y_train, minibatch_size)
...         for X_train_mini, y_train_mini in minibatch_gen:
...             #### Compute outputs ####
...             a_h, a_out = model.forward(X_train_mini)
...             #### Compute gradients ####
...             d_loss__d_w_out, d_loss__d_b_out, \
...             d_loss__d_w_h, d_loss__d_b_h = \
...                 model.backward(X_train_mini, a_h, a_out,
...                                y_train_mini)
...
...             #### Update weights ####
...             model.weight_h -= learning_rate * d_loss__d_w_h
...             model.bias_h -= learning_rate * d_loss__d_b_h
...             model.weight_out -= learning_rate * d_loss__d_w_out
...             model.bias_out -= learning_rate * d_loss__d_b_out
...         
...         #### Epoch Logging ####
...         train_mse, train_acc = compute_mse_and_acc(
...             model, X_train, y_train
...         )
...         valid_mse, valid_acc = compute_mse_and_acc(
...             model, X_valid, y_valid
...         )
...         train_acc, valid_acc = train_acc*100, valid_acc*100
...         epoch_train_acc.append(train_acc)
...         epoch_valid_acc.append(valid_acc)
...         epoch_loss.append(train_mse)
...         print(f'Epoch: {e+1:03d}/{num_epochs:03d} '
...               f'| Train MSE: {train_mse:.2f} '
...               f'| Train Acc: {train_acc:.2f}% '
...               f'| Valid Acc: {valid_acc:.2f}%')
...
...     return epoch_loss, epoch_train_acc, epoch_valid_acc 

从高层次上来看,train() 函数迭代多个 epoch,在每个 epoch 中,它使用之前定义的 minibatch_generator() 函数以小批量进行随机梯度下降训练整个训练集。在小批量生成器的 for 循环内部,我们通过模型的 .forward() 方法获取模型的输出 a_ha_out。然后,我们通过模型的 .backward() 方法计算损失梯度——这个理论将在后面的部分中解释。利用损失梯度,我们通过学习率乘以负梯度来更新权重。这与我们之前为 Adaline 讨论的概念相同。例如,要更新隐藏层的模型权重,我们定义了以下行:

model.weight_h -= learning_rate * d_loss__d_w_h 

对于单个权重 w[j],这对应于以下基于偏导数的更新:

最后,前面代码的最后部分计算了训练集和测试集上的损失和预测准确率,以跟踪训练进展。

现在让我们执行此函数,训练我们的模型 50 个时期,可能需要几分钟才能完成:

>>> np.random.seed(123) # for the training set shuffling
>>> epoch_loss, epoch_train_acc, epoch_valid_acc = train(
...     model, X_train, y_train, X_valid, y_valid,
...     num_epochs=50, learning_rate=0.1) 

在训练过程中,我们应该看到以下输出:

Epoch: 001/050 | Train MSE: 0.05 | Train Acc: 76.17% | Valid Acc: 76.02%
Epoch: 002/050 | Train MSE: 0.03 | Train Acc: 85.46% | Valid Acc: 84.94%
Epoch: 003/050 | Train MSE: 0.02 | Train Acc: 87.89% | Valid Acc: 87.64%
Epoch: 004/050 | Train MSE: 0.02 | Train Acc: 89.36% | Valid Acc: 89.38%
Epoch: 005/050 | Train MSE: 0.02 | Train Acc: 90.21% | Valid Acc: 90.16%
...
Epoch: 048/050 | Train MSE: 0.01 | Train Acc: 95.57% | Valid Acc: 94.58%
Epoch: 049/050 | Train MSE: 0.01 | Train Acc: 95.55% | Valid Acc: 94.54%
Epoch: 050/050 | Train MSE: 0.01 | Train Acc: 95.59% | Valid Acc: 94.74% 

打印所有这些输出的原因是,在神经网络训练中,比较训练和验证精度真的很有用。这有助于我们判断网络模型在给定架构和超参数情况下的表现是否良好。例如,如果我们观察到低训练和验证精度,则训练数据集可能存在问题,或者超参数设置不理想。

总的来说,训练(深度)神经网络相对于我们到目前为止讨论的其他模型来说成本相对较高。因此,在某些情况下,我们希望及早停止,并使用不同的超参数设置重新开始。另一方面,如果我们发现它越来越倾向于过拟合训练数据(通过训练和验证数据集性能之间逐渐增加的差距可察觉),我们也可能希望提前停止训练。

在下一小节中,我们将更详细地讨论我们的神经网络模型的性能。

评估神经网络性能

在我们在下一节更详细讨论神经网络的反向传播(NNs)训练过程之前,让我们先看看我们在前一小节中训练的模型的性能。

train()中,我们收集了每个时期的训练损失以及训练和验证精度,以便可以使用 Matplotlib 可视化结果。让我们先看一下训练 MSE 损失:

>>> plt.plot(range(len(epoch_loss)), epoch_loss)
>>> plt.ylabel('Mean squared error')
>>> plt.xlabel('Epoch')
>>> plt.show() 

前述代码绘制了 50 个时期内的损失,如图 11.7所示:

形状,正方形 由自动生成的描述

图 11.7:MSE 与训练时期数量的图表

如我们所见,在前 10 个时期内损失大幅减少,并在后 10 个时期内缓慢收敛。然而,在第 40 到第 50 个时期之间的小斜率表明,随着额外的时期训练,损失还将进一步减少。

接下来,让我们来看看训练和验证精度:

>>> plt.plot(range(len(epoch_train_acc)), epoch_train_acc,
...          label='Training')
>>> plt.plot(range(len(epoch_valid_acc)), epoch_valid_acc,
...          label='Validation')
>>> plt.ylabel('Accuracy')
>>> plt.xlabel('Epochs')
>>> plt.legend(loc='lower right')
>>> plt.show() 

前述代码示例绘制了这些准确率值在 50 个训练时期内的情况,如图 11.8所示:

包含图形用户界面的图片 由自动生成的描述

图 11.8:训练时期分类准确率

绘图显示,随着训练的进行,训练和验证精度之间的差距逐渐增大。在大约第 25 个时期,训练和验证精度几乎相等,然后网络开始略微过拟合训练数据。

减少过拟合

减少过拟合效果的一种方法是通过 L2 正则化增加正则化强度,我们在 第三章使用 Scikit-Learn 进行机器学习分类器的简介 中介绍过。在 NN 中应对过拟合的另一种有用技术是 dropout,在 第十四章使用深度卷积神经网络对图像进行分类 中将进行介绍。

最后,让我们通过计算在测试数据集上的预测准确率来评估模型的泛化性能:

>>> test_mse, test_acc = compute_mse_and_acc(model, X_test, y_test)
>>> print(f'Test accuracy: {test_acc*100:.2f}%')
Test accuracy: 94.51% 

我们可以看到测试准确率非常接近验证集准确率,对应于最后一个 epoch(94.74%),我们在上一个小节的训练中报告过。此外,相应的训练准确率仅略高于 95.59%,再次确认我们的模型仅轻微过拟合训练数据。

要进一步微调模型,我们可以改变隐藏单元的数量、学习率,或者使用多年来开发的其他各种技巧,但这超出了本书的范围。在 第十四章使用深度卷积神经网络对图像进行分类,您将了解到一种不同的 NN 架构,以其在图像数据集上的良好性能而闻名。

此外,本章还将介绍其他增强性能的技巧,如自适应学习率、更复杂的基于 SGD 的优化算法、批归一化和 dropout。

其他常见的技巧超出了以下章节的范围:

  • 添加跳跃连接,这是残差神经网络的主要贡献(深度残差学习用于图像识别,作者 K. He, X. Zhang, S. RenJ. SunIEEE 计算机视觉与模式识别会议论文集,2016 年,第 770-778 页)

  • 使用学习率调度程序,在训练过程中改变学习率(用于训练神经网络的循环学习率,作者 L.N. Smith2017 IEEE 冬季计算机视觉应用会议,2017 年,第 464-472 页)

  • 将损失函数附加到网络中较早的层中,就像在流行的 Inception v3 架构中所做的那样(重新思考 Inception 架构用于计算机视觉,作者 C. Szegedy, V. Vanhoucke, S. Ioffe, J. ShlensZ. WojnaIEEE 计算机视觉与模式识别会议论文集,2016 年,第 2818-2826 页)

最后,让我们看一些我们的多层感知器在测试集中提取和绘制的前 25 个错误分类样本的图片:

>>> X_test_subset = X_test[:1000, :]
>>> y_test_subset = y_test[:1000]
>>> _, probas = model.forward(X_test_subset)
>>> test_pred = np.argmax(probas, axis=1)
>>> misclassified_images = \
...      X_test_subset[y_test_subset != test_pred][:25]
>>> misclassified_labels = test_pred[y_test_subset != test_pred][:25]
>>> correct_labels = y_test_subset[y_test_subset != test_pred][:25]
>>> fig, ax = plt.subplots(nrows=5, ncols=5,
...                        sharex=True, sharey=True,
...                        figsize=(8, 8))
>>> ax = ax.flatten()
>>> for i in range(25):
...     img = misclassified_images[i].reshape(28, 28)
...     ax[i].imshow(img, cmap='Greys', interpolation='nearest')
...     ax[i].set_title(f'{i+1}) '
...                     f'True: {correct_labels[i]}\n'
...                     f' Predicted: {misclassified_labels[i]}')
>>> ax[0].set_xticks([])
>>> ax[0].set_yticks([])
>>> plt.tight_layout()
>>> plt.show() 

现在我们应该看到一个 5×5 的子图矩阵,其中副标题中的第一个数字表示图表索引,第二个数字代表真实类标签(True),第三个数字表示预测类标签(Predicted):

包含文本、电子设备、键盘的图片 描述已自动生成

图 11.9:模型无法正确分类的手写数字

正如我们在图 11.9中所看到的,网络在包含水平线的 7 时会感到挑战,例如第 19 和第 20 个例子。回顾本章早期的一个图中,我们绘制了数字 7 的不同训练示例,我们可以假设,带有水平线的手写数字 7 在我们的数据集中很少出现,并且经常被错误分类。

训练人工神经网络

现在我们已经看到了一个神经网络的运行,并通过查看代码获得了对其工作方式的基本理解,让我们深入挖掘一些概念,如损失计算和我们实现的反向传播算法,以学习模型参数。

计算损失函数

如前所述,我们使用了 MSE 损失(如 Adaline 中的损失)来训练多层 NN,因为这样做可以更容易地推导出梯度。在后续章节中,我们将讨论其他损失函数,如多类别交叉熵损失(二元逻辑回归损失的一般化),这是训练 NN 分类器更常见的选择。

在前面的部分,我们实现了一个用于多类分类的 MLP,它返回一个t元素的输出向量,我们需要将其与一个t×1 维的目标向量(使用独热编码表示)进行比较。如果我们使用这个 MLP 来预测一个输入图像的类标签为 2,则第三层的激活和目标可能如下所示:

因此,我们的 MSE 损失不仅必须在网络中的t个激活单元上求和或平均,还必须在数据集或小批量中的n个示例上进行平均:

在这里,再次提到,上标[i]是我们训练数据集中特定示例的索引。

请记住,我们的目标是最小化损失函数L(W),因此我们需要计算网络中每一层的参数W相对于每个权重的偏导数:

在接下来的部分中,我们将讨论反向传播算法,它允许我们计算这些偏导数以最小化损失函数。

请注意,W由多个矩阵组成。在一个具有一个隐藏层的 MLP 中,我们有连接输入到隐藏层的权重矩阵W^(^h^),以及连接隐藏层到输出层的权重矩阵W^(^(out)^)。三维张量W的可视化如图 11.10所示:

图示,工程图 自动生成的说明

图 11.10:三维张量的可视化

在这个简化的图中,似乎W^(^h^)和W^(^(out)^)的行数和列数相同,但通常情况下并非如此,除非我们初始化一个具有相同隐藏单元数、输出单元数和输入特征的 MLP。

如果这听起来让人困惑,那么请继续关注下一节内容,在那里我们将更详细地讨论在反向传播算法的背景下 W^(^h^) 和 W^(^(out)^) 的维度问题。此外,你被鼓励再次阅读 NeuralNetMLP 的代码,其中有关不同矩阵和向量转换维度的注释。

发展你对反向传播的理解

虽然反向传播是 30 多年前被引入神经网络社区的(*由 D.E. Rumelhart、G.E. Hinton 和 R.J. Williams 所著,《自然》323: 6088, 页码 533–536, 1986),但它仍然是训练人工神经网络非常高效的最广泛使用的算法之一。如果你对反向传播的历史有兴趣,Juergen Schmidhuber 写了一篇很好的调查文章,《谁发明了反向传播?》,你可以在这里找到:people.idsia.ch/~juergen/who-invented-backpropagation.html

在我们深入更多数学细节之前,本节将提供一个简短而清晰的总结,并展示这一迷人算法的整体图景。本质上,我们可以将反向传播视为一种非常高效的方法,用于计算多层神经网络中复杂非凸损失函数的偏导数。在这里,我们的目标是利用这些导数来学习参数化这样的多层人工神经网络的权重系数。在神经网络参数化中的挑战是,我们通常处理的是高维特征空间中的大量模型参数。与单层神经网络如 Adaline 或 logistic 回归的损失函数不同,这些神经网络损失函数的误差表面对参数不是凸的或光滑的。在这种高维损失表面上有许多隆起(局部最小值),我们必须克服这些隆起,以找到损失函数的全局最小值。

你可能还记得在你的初级微积分课程中提到过链式法则的概念。链式法则是计算复杂的嵌套函数(例如 f(g(x))) 导数的一种方法,如下所示:

同样地,我们可以对任意长的函数组合使用链式法则。例如,假设我们有五个不同的函数 f(x), g(x), h(x), u(x), 和 v(x),并且 F 是函数的组合:F(x) = f(g(h(u(v(x)))))。应用链式法则,我们可以计算这个函数的导数如下:

在计算代数的背景下,开发了一套称为自动微分的技术来非常高效地解决此类问题。如果您有兴趣了解机器学习应用中的自动微分更多信息,请阅读 A.G. Baydin 和 B.A. Pearlmutter 的文章,Automatic Differentiation of Algorithms for Machine Learning,arXiv 预印本 arXiv:1404.7456,2014 年,可以在 arXiv 上免费获取,网址为arxiv.org/pdf/1404.7456.pdf

自动微分有两种模式,前向模式和反向模式;反向传播只是反向模式自动微分的一个特例。关键点在于,在前向模式中应用链式法则可能非常昂贵,因为我们需要为每一层(雅可比矩阵)乘以一个大矩阵,最终将其乘以一个向量以获得输出。

反向模式的诀窍在于,我们从右到左遍历链式法则。我们将一个矩阵乘以一个向量,得到另一个乘以下一个矩阵的向量,依此类推。矩阵向量乘法在计算上比矩阵矩阵乘法便宜得多,这就是为什么反向传播是 NN 训练中最流行的算法之一。

基础微积分复习

要完全理解反向传播,我们需要借鉴微分学的某些概念,这超出了本书的范围。但是,您可以参考一些最基本概念的复习章节,这在这种情况下可能会对您有所帮助。它讨论了函数导数、偏导数、梯度和雅可比矩阵。这本文在sebastianraschka.com/pdf/books/dlb/appendix_d_calculus.pdf上免费获取。如果您对微积分不熟悉或需要简要复习,请在阅读下一节之前考虑阅读此文作为额外支持资源。

通过反向传播训练神经网络

在这一部分中,我们将通过反向传播的数学来理解如何高效学习神经网络中的权重。根据您对数学表示的熟悉程度,以下方程可能一开始看起来相对复杂。

在前面的章节中,我们看到了如何计算损失,即最后一层的激活与目标类别标签之间的差异。现在,我们将看看反向传播算法如何从数学角度上更新我们的 MLP 模型中的权重,我们在NeuralNetMLP()类的.backward()方法中实现了这一点。正如我们在本章开头所提到的,我们首先需要应用前向传播来获取输出层的激活,我们将其表述如下:

简而言之,我们只需将输入特征通过网络中的连接进行前向传播,如图 11.11中显示的箭头所示,用于具有两个输入特征、三个隐藏节点和两个输出节点的网络:

图表 说明自动生成

图 11.11:前向传播 NN 的输入特征

在反向传播中,我们从右到左传播误差。我们可以将这看作是链式法则应用于计算前向传递以计算损失相对于模型权重(和偏置单元)的梯度。为简单起见,我们将用于更新输出层权重矩阵中第一个权重的偏导数的此过程进行说明。我们反向传播的计算路径通过下面的粗体箭头突出显示:

图表 说明自动生成

图 11.12:反向传播 NN 的误差

如果我们明确包含净输入z,则在上一个图中显示的偏导数计算扩展如下:

要计算这个偏导数,用于更新,我们可以计算三个单独的偏导数项并将结果相乘。为简单起见,我们将省略在小批量中对各个示例的平均,因此从以下方程中删除的平均项。

让我们从开始,这是 MSE 损失的偏导数(如果我们省略小批量维度,则简化为平方误差)相对于第一个输出节点的预测输出分数:

下一个项是我们在输出层中使用的 logistic sigmoid 激活函数的导数:

最后,我们计算净输入相对于权重的导数:

将所有这些放在一起,我们得到以下内容:

然后,我们使用此值通过学习率为的熟悉随机梯度下降更新权重:

在我们的NeuralNetMLP()代码实现中,我们以向量化形式在.backward()方法中实现了的计算:

 # Part 1: dLoss/dOutWeights
        ## = dLoss/dOutAct * dOutAct/dOutNet * dOutNet/dOutWeight
        ## where DeltaOut = dLoss/dOutAct * dOutAct/dOutNet for convenient re-use

        # input/output dim: [n_examples, n_classes]
        d_loss__d_a_out = 2.*(a_out - y_onehot) / y.shape[0]
        # input/output dim: [n_examples, n_classes]
        d_a_out__d_z_out = a_out * (1\. - a_out) # sigmoid derivative
        # output dim: [n_examples, n_classes]
        delta_out = d_loss__d_a_out * d_a_out__d_z_out # "delta (rule)
                                                       # placeholder"
        # gradient for output weights

        # [n_examples, n_hidden]
        d_z_out__dw_out = a_h

        # input dim: [n_classes, n_examples] dot [n_examples, n_hidden]
        # output dim: [n_classes, n_hidden]
        d_loss__dw_out = np.dot(delta_out.T, d_z_out__dw_out)
        d_loss__db_out = np.sum(delta_out, axis=0) 
 the following “delta” placeholder variable:

这是因为在计算隐藏层权重的偏导数(或梯度)时涉及到项;因此,我们可以重复使用

谈到隐藏层权重,图 11.13说明了如何计算与隐藏层第一个权重相关的损失的偏导数:

包含文本、时钟的图片 说明自动生成

图 11.13:计算与第一个隐藏层权重相关的损失的偏导数

值得强调的是,由于权重 连接到两个输出节点,我们必须使用多变量链式法则来求和用粗箭头突出显示的两条路径。像以前一样,我们可以扩展它以包括净输入 z,然后解决各个术语:

请注意,如果我们重复使用先前计算的 ,则可以将此方程简化如下:

由于之前已经单独解决了前述术语,因此相对容易解决,因为没有涉及新的导数。例如,是 S 形激活函数的导数,即,等等。我们将留给您作为可选练习来解决各个部分。

关于神经网络中的收敛问题

也许你会想知道,为什么我们没有使用常规梯度下降,而是使用小批量学习来训练我们的手写数字分类 NN。你可能还记得我们使用的在线学习 SGD 的讨论。在在线学习中,我们每次基于单个训练示例(k = 1)计算梯度以执行权重更新。虽然这是一种随机方法,但通常比常规梯度下降快得多地收敛到非常准确的解决方案。小批量学习是 SGD 的一种特殊形式,在这种形式中,我们基于n个训练示例中的子集k计算梯度,其中 1 < k < n。小批量学习比在线学习的优势在于,我们可以利用矢量化实现来提高计算效率。然而,我们可以比常规梯度下降更新权重得更快。直觉上,你可以将小批量学习视为预测总统选举中选民投票率的一种方式,通过询问人口的代表性子集,而不是询问整个人口(这等同于进行实际选举)。

多层神经网络比诸如 Adaline、逻辑回归或支持向量机等简单算法难训练得多。在多层神经网络中,我们通常需要优化成百上千甚至数十亿个权重。不幸的是,输出函数的曲面粗糙,优化算法很容易陷入局部最小值,如图 11.14所示:

Diagram  Description automatically generated

图 11.14:优化算法可能陷入局部最小值

请注意,由于我们的神经网络具有多个维度,这种表示极为简化,使得人眼无法可视化实际的损失曲面。在这里,我们只展示了单个权重在x轴上的损失曲面。然而,主要信息是我们不希望我们的算法陷入局部最小值。通过增加学习率,我们可以更容易地逃离这种局部最小值。另一方面,如果学习率过大,也会增加超越全局最优解的风险。由于我们随机初始化权重,因此我们从根本上开始解决优化问题的解通常是完全错误的。

关于神经网络实现的最后几句话

您可能会想知道为什么我们要通过所有这些理论来实现一个简单的多层人工网络,而不是使用开源的 Python 机器学习库。实际上,在接下来的章节中,我们将介绍更复杂的神经网络模型,我们将使用开源的 PyTorch 库进行训练(pytorch.org)。

虽然本章中的从零开始实现起初看起来有些乏味,但对理解反向传播和神经网络训练背后的基础知识是一个很好的练习。对算法的基本理解对适当和成功地应用机器学习技术至关重要。

现在您已经了解了前馈神经网络的工作原理,我们准备使用 PyTorch 探索更复杂的深度神经网络,这使得我们可以更高效地构建神经网络,正如我们将在第十二章使用 PyTorch 并行化神经网络训练中看到的。

PyTorch 最初发布于 2016 年 9 月,已经在机器学习研究人员中广受欢迎,他们使用它构建深度神经网络,因为它能够优化在多维数组上计算的数学表达式,利用图形处理单元GPU)。

最后,我们应该注意,scikit-learn 还包括一个基本的 MLP 实现,MLPClassifier,您可以在scikit-learn.org/stable/modules/generated/sklearn.neural_network.MLPClassifier.html找到。虽然这种实现非常方便用于训练基本的 MLP,但我们强烈推荐使用专门的深度学习库,如 PyTorch,来实现和训练多层神经网络。

摘要

在本章中,你已经学习了多层人工神经网络背后的基本概念,这是当前机器学习研究中最热门的话题。在第二章中,训练简单的机器学习算法进行分类,我们从简单的单层神经网络结构开始我们的旅程,现在我们已经将多个神经元连接到一个强大的神经网络架构中,以解决如手写数字识别等复杂问题。我们揭开了流行的反向传播算法的神秘面纱,这是许多深度学习模型的基石之一。在本章学习了反向传播算法之后,我们已经准备好探索更复杂的深度神经网络架构。在接下来的章节中,我们将涵盖更高级的深度学习概念以及 PyTorch,这是一个开源库,可以更有效地实现和训练多层神经网络。

加入我们书籍的 Discord 空间

加入本书作者的 Discord 工作区,每月进行问我任何事会话:

packt.link/MLwPyTorch

第十二章:使用 PyTorch 并行化神经网络训练

在本章中,我们将从机器学习和深度学习的数学基础转向 PyTorch。PyTorch 是目前最流行的深度学习库之一,它让我们比以前的任何 NumPy 实现更高效地实现神经网络NNs)。在本章中,我们将开始使用 PyTorch,看看它如何显著提升训练性能。

本章将开始我们进入机器学习和深度学习的下一阶段的旅程,我们将探讨以下主题:

  • PyTorch 如何提升训练性能

  • 使用 PyTorch 的 DatasetDataLoader 构建输入管道,实现高效的模型训练

  • 使用 PyTorch 编写优化的机器学习代码

  • 使用 torch.nn 模块方便地实现常见的深度学习架构

  • 选择人工神经网络的激活函数

PyTorch 和训练性能

PyTorch 可以显著加速我们的机器学习任务。要理解它是如何做到这一点的,请让我们首先讨论我们在执行昂贵计算时通常遇到的一些性能挑战。然后,我们将从高层次来看 PyTorch 是什么,以及本章中我们的学习方法会是什么样的。

性能挑战

当然,计算机处理器的性能在近年来一直在不断提升。这使得我们能够训练更强大和复杂的学习系统,这意味着我们可以提高机器学习模型的预测性能。即使是现在最便宜的桌面计算机硬件也配备有具有多个核心的处理单元。

在前几章中,我们看到 scikit-learn 中的许多函数允许我们将计算分布到多个处理单元上。然而,默认情况下,由于全局解释器锁GIL),Python 只能在一个核心上执行。因此,尽管我们确实利用 Python 的多进程库将计算分布到多个核心上,但我们仍然必须考虑,即使是最先进的桌面硬件也很少配备超过 8 或 16 个这样的核心。

你会回忆起第十一章从头开始实现多层人工神经网络,我们实现了一个非常简单的多层感知器MLP),只有一个包含 100 个单元的隐藏层。我们必须优化大约 80,000 个权重参数([784*100 + 100] + [100 * 10] + 10 = 79,510)来进行一个非常简单的图像分类任务。MNIST 数据集中的图像相当小(28×28),如果我们想要添加额外的隐藏层或者处理像素密度更高的图像,我们可以想象参数数量的激增。这样的任务很快就会对单个处理单元变得不可行。因此问题变成了,我们如何更有效地解决这些问题?

这个问题的显而易见的解决方案是使用图形处理单元GPUs),它们是真正的工作马。你可以把显卡想象成你的机器内部的一个小型计算机集群。另一个优势是,与最先进的中央处理单元CPUs)相比,现代 GPU 性价比非常高,如下面的概述所示:

图 12.1:现代 CPU 和 GPU 的比较

图 12.1中信息的来源是以下网站(访问日期:2021 年 7 月):

以现代 CPU 的价格的 2.2 倍,我们可以获得一个 GPU,它拥有 640 倍的核心数,并且每秒可以进行大约 46 倍的浮点计算。那么,是什么阻碍了我们利用 GPU 来进行机器学习任务?挑战在于编写目标为 GPU 的代码并不像在解释器中执行 Python 代码那么简单。有一些特殊的包,比如 CUDA 和 OpenCL,可以让我们针对 GPU 进行编程。然而,用 CUDA 或 OpenCL 编写代码可能不是实现和运行机器学习算法的最方便的方式。好消息是,这正是 PyTorch 开发的目的!

什么是 PyTorch?

PyTorch 是一个可扩展且多平台的编程接口,用于实现和运行机器学习算法,包括深度学习的便捷包装器。PyTorch 主要由来自Facebook AI ResearchFAIR)实验室的研究人员和工程师开发。其开发还涉及来自社区的许多贡献。PyTorch 最初发布于 2016 年 9 月,以修改的 BSD 许可证免费开源。许多来自学术界和工业界的机器学习研究人员和从业者已经采用 PyTorch 来开发深度学习解决方案,例如 Tesla Autopilot、Uber 的 Pyro 和 Hugging Face 的 Transformers(pytorch.org/ecosystem/)。

为了提高训练机器学习模型的性能,PyTorch 允许在 CPU、GPU 和 XLA 设备(如 TPU)上执行。然而,当使用 GPU 和 XLA 设备时,PyTorch 具有最优的性能能力。PyTorch 官方支持 CUDA 启用和 ROCm GPU。PyTorch 的开发基于 Torch 库(www.torch.ch)。顾名思义,Python 接口是 PyTorch 的主要开发重点。

PyTorch 围绕着一个计算图构建,由一组节点组成。每个节点表示一个可能有零个或多个输入或输出的操作。PyTorch 提供了一种即时评估操作、执行计算并立即返回具体值的命令式编程环境。因此,PyTorch 中的计算图是隐式定义的,而不是事先构建并在执行之后执行。

从数学上讲,张量可以理解为标量、向量、矩阵等的一般化。更具体地说,标量可以定义为秩为 0 的张量,向量可以定义为秩为 1 的张量,矩阵可以定义为秩为 2 的张量,而在第三维堆叠的矩阵可以定义为秩为 3 的张量。PyTorch 中的张量类似于 NumPy 的数组,但张量经过了优化以进行自动微分并能在 GPU 上运行。

要更清晰地理解张量的概念,请参考图 12.2,该图展示了第一行中秩为 0 和 1 的张量,以及第二行中秩为 2 和 3 的张量:

图 12.2:PyTorch 中不同类型的张量

现在我们知道了 PyTorch 是什么,让我们看看如何使用它。

我们将如何学习 PyTorch

首先,我们将介绍 PyTorch 的编程模型,特别是如何创建和操作张量。然后,我们将看看如何加载数据并利用torch.utils.data模块,这将允许我们高效地迭代数据集。此外,我们将讨论torch.utils.data.Dataset子模块中现有的即用即得数据集,并学习如何使用它们。

在学习了这些基础知识后,PyTorch 神经网络模块 torch.nn 将被介绍。然后,我们将继续构建机器学习模型,学习如何组合和训练这些模型,并了解如何将训练好的模型保存在磁盘上以供未来评估使用。

PyTorch 的首次使用步骤

在本节中,我们将初步了解使用低级别的 PyTorch API。在安装 PyTorch 后,我们将介绍如何在 PyTorch 中创建张量以及不同的操作方法,例如更改它们的形状、数据类型等。

安装 PyTorch

要安装 PyTorch,建议参阅官方网站 pytorch.org 上的最新说明。以下是适用于大多数系统的基本步骤概述。

根据系统设置的不同,通常您只需使用 Python 的 pip 安装程序,并通过终端执行以下命令从 PyPI 安装 PyTorch:

pip install torch torchvision 

这将安装最新的 稳定 版本,在撰写时是 1.9.0。要安装 1.9.0 版本,该版本确保与以下代码示例兼容,您可以按照以下方式修改前述命令:

pip install torch==1.9.0 torchvision==0.10.0 

如果您希望使用 GPU(推荐),则需要一台兼容 CUDA 和 cuDNN 的 NVIDIA 显卡。如果您的计算机符合这些要求,您可以按照以下步骤安装支持 GPU 的 PyTorch:

pip install torch==1.9.0+cu111 torchvision==0.10.0+cu111 -f https://download.pytorch.org/whl/torch_stable.html 

适用于 CUDA 11.1 或:

pip install torch==1.9.0 torchvision==0.10.0\  -f https://download.pytorch.org/whl/torch_stable.html 

目前为止适用于 CUDA 10.2。

由于 macOS 二进制版本不支持 CUDA,您可以从源代码安装:pytorch.org/get-started/locally/#mac-from-source

关于安装和设置过程的更多信息,请参阅官方建议,网址为pytorch.org/get-started/locally/

请注意,PyTorch 处于活跃开发阶段,因此每隔几个月就会发布带有重大更改的新版本。您可以通过终端验证您的 PyTorch 版本,方法如下:

python -c 'import torch; print(torch.__version__)' 

解决 PyTorch 安装问题

如果您在安装过程中遇到问题,请阅读有关特定系统和平台的推荐信息,网址为pytorch.org/get-started/locally/。请注意,本章中的所有代码都可以在您的 CPU 上运行;使用 GPU 完全是可选的,但如果您想充分享受 PyTorch 的好处,则建议使用 GPU。例如,使用 CPU 训练某些神经网络模型可能需要一周时间,而在现代 GPU 上,同样的模型可能只需几小时。如果您有显卡,请参考安装页面适当设置。此外,您可能会发现这篇设置指南有用,其中解释了如何在 Ubuntu 上安装 NVIDIA 显卡驱动程序、CUDA 和 cuDNN(虽然不是运行 PyTorch 在 GPU 上所需的必备条件,但推荐要求):sebastianraschka.com/pdf/books/dlb/appendix_h_cloud-computing.pdf。此外,正如您将在第十七章中看到的,生成对抗网络用于合成新数据,您还可以免费使用 Google Colab 通过 GPU 训练您的模型。

在 PyTorch 中创建张量

现在,让我们考虑几种不同的方式来创建张量,然后看看它们的一些属性以及如何操作它们。首先,我们可以使用torch.tensortorch.from_numpy函数从列表或 NumPy 数组创建张量,如下所示:

>>> import torch
>>> import numpy as np
>>> np.set_printoptions(precision=3)
>>> a = [1, 2, 3]
>>> b = np.array([4, 5, 6], dtype=np.int32)
>>> t_a = torch.tensor(a)
>>> t_b = torch.from_numpy(b)
>>> print(t_a)
>>> print(t_b)
tensor([1, 2, 3])
tensor([4, 5, 6], dtype=torch.int32) 

这导致了张量t_at_b,它们的属性为,shape=(3,)dtype=int32,这些属性是从它们的源头继承而来。与 NumPy 数组类似,我们也可以看到这些属性:

>>> t_ones = torch.ones(2, 3)
>>> t_ones.shape
torch.Size([2, 3])
>>> print(t_ones)
tensor([[1., 1., 1.],
        [1., 1., 1.]]) 

最后,可以如下方式创建随机值张量:

>>> rand_tensor = torch.rand(2,3)
>>> print(rand_tensor)
tensor([[0.1409, 0.2848, 0.8914],
        [0.9223, 0.2924, 0.7889]]) 

操作张量的数据类型和形状

学习如何操作张量以使它们适合模型或操作的输入是必要的。在本节中,您将通过几个 PyTorch 函数学习如何通过类型转换、重塑、转置和挤压(去除维度)来操作张量的数据类型和形状。

torch.to()函数可用于将张量的数据类型更改为所需类型:

>>> t_a_new = t_a.to(torch.int64)
>>> print(t_a_new.dtype)
torch.int64 

请查看pytorch.org/docs/stable/tensor_attributes.html获取所有其他数据类型的信息。

正如您将在接下来的章节中看到的,某些操作要求输入张量具有特定数量的维度(即秩),并与一定数量的元素(形状)相关联。因此,我们可能需要改变张量的形状,添加一个新维度或挤压一个不必要的维度。PyTorch 提供了一些有用的函数(或操作)来实现这一点,如torch.transpose()torch.reshape()torch.squeeze()。让我们看一些例子:

  • 转置张量:

    >>> t = torch.rand(3, 5)
    >>> t_tr = torch.transpose(t, 0, 1)
    >>> print(t.shape, ' --> ', t_tr.shape)
    torch.Size([3, 5])  -->  torch.Size([5, 3]) 
    
  • 重塑张量(例如,从 1D 向量到 2D 数组):

    >>> t = torch.zeros(30)
    >>> t_reshape = t.reshape(5, 6)
    >>> print(t_reshape.shape)
    torch.Size([5, 6]) 
    
  • 移除不必要的维度(即大小为 1 的维度):

    >>> t = torch.zeros(1, 2, 1, 4, 1)
    >>> t_sqz = torch.squeeze(t, 2)
    >>> print(t.shape, ' --> ', t_sqz.shape)
    torch.Size([1, 2, 1, 4, 1])  -->  torch.Size([1, 2, 4, 1]) 
    

对张量应用数学操作

应用数学运算,特别是线性代数运算,是构建大多数机器学习模型所必需的。在这个子节中,我们将介绍一些广泛使用的线性代数操作,例如逐元素乘积、矩阵乘法和计算张量的范数。

首先,让我们实例化两个随机张量,一个具有在[–1, 1)范围内均匀分布的值,另一个具有标准正态分布:

>>> torch.manual_seed(1)
>>> t1 = 2 * torch.rand(5, 2) - 1
>>> t2 = torch.normal(mean=0, std=1, size=(5, 2)) 

注意,torch.rand返回一个填充有从[0, 1)范围内均匀分布的随机数的张量。

注意,t1t2具有相同的形状。现在,要计算t1t2的逐元素乘积,可以使用以下方法:

>>> t3 = torch.multiply(t1, t2)
>>> print(t3)
tensor([[ 0.4426, -0.3114], 
        [ 0.0660, -0.5970], 
        [ 1.1249,  0.0150], 
        [ 0.1569,  0.7107], 
        [-0.0451, -0.0352]]) 

要沿着某个轴(或轴)计算均值、总和和标准偏差,可以使用torch.mean()torch.sum()torch.std()。例如,可以如下计算t1中每列的均值:

>>> t4 = torch.mean(t1, axis=0)
>>> print(t4)
tensor([-0.1373,  0.2028]) 

使用torch.matmul()函数可以计算t1t2的矩阵乘积(即,,其中上标 T 表示转置):

>>> t5 = torch.matmul(t1, torch.transpose(t2, 0, 1))
>>> print(t5)
tensor([[ 0.1312,  0.3860, -0.6267, -1.0096, -0.2943],
        [ 0.1647, -0.5310,  0.2434,  0.8035,  0.1980],
        [-0.3855, -0.4422,  1.1399,  1.5558,  0.4781],
        [ 0.1822, -0.5771,  0.2585,  0.8676,  0.2132],
        [ 0.0330,  0.1084, -0.1692, -0.2771, -0.0804]]) 

另一方面,通过对t1进行转置来计算,结果是一个大小为 2×2 的数组:

>>> t6 = torch.matmul(torch.transpose(t1, 0, 1), t2)
>>> print(t6)
tensor([[ 1.7453,  0.3392],
        [-1.6038, -0.2180]]) 

最后,torch.linalg.norm()函数对于计算张量的L^p 范数非常有用。例如,我们可以如下计算t1L²范数:

>>> norm_t1 = torch.linalg.norm(t1, ord=2, dim=1)
>>> print(norm_t1)
tensor([0.6785, 0.5078, 1.1162, 0.5488, 0.1853]) 
L2 norm of t1 correctly, you can compare the results with the following NumPy function: np.sqrt(np.sum(np.square(t1.numpy()), axis=1)).

分割、堆叠和连接张量

在这个子节中,我们将介绍 PyTorch 操作,用于将一个张量分割成多个张量,或者反过来,将多个张量堆叠和连接成一个单独的张量。

假设我们有一个单一的张量,并且我们想将它分成两个或更多的张量。为此,PyTorch 提供了一个便捷的torch.chunk()函数,它将输入张量分割成等大小的张量列表。我们可以使用chunks参数作为整数来确定所需的分割数,以dim参数指定沿所需维度分割张量。在这种情况下,沿指定维度的输入张量的总大小必须是所需分割数的倍数。另外,我们可以使用torch.split()函数在列表中提供所需的大小。让我们看看这两个选项的示例:

  • 提供分割数量:

    >>> torch.manual_seed(1)
    >>> t = torch.rand(6)
    >>> print(t)
    tensor([0.7576, 0.2793, 0.4031, 0.7347, 0.0293, 0.7999])
    >>> t_splits = torch.chunk(t, 3)
    >>> [item.numpy() for item in t_splits]
    [array([0.758, 0.279], dtype=float32),
     array([0.403, 0.735], dtype=float32),
     array([0.029, 0.8  ], dtype=float32)] 
    

    在这个例子中,一个大小为 6 的张量被分割成了一个包含三个大小为 2 的张量的列表。如果张量大小不能被chunks值整除,则最后一个块将更小。

  • 提供不同分割的大小:

    或者,可以直接指定输出张量的大小,而不是定义分割的数量。在这里,我们将一个大小为5的张量分割为大小为32的张量:

    >>> torch.manual_seed(1)
    >>> t = torch.rand(5)
    >>> print(t)
    tensor([0.7576, 0.2793, 0.4031, 0.7347, 0.0293])
    >>> t_splits = torch.split(t, split_size_or_sections=[3, 2])
    >>> [item.numpy() for item in t_splits]
    [array([0.758, 0.279, 0.403], dtype=float32),
     array([0.735, 0.029], dtype=float32)] 
    

有时,我们需要处理多个张量,并需要将它们连接或堆叠以创建一个单一的张量。在这种情况下,PyTorch 的函数如torch.stack()torch.cat()非常方便。例如,让我们创建一个包含大小为3的 1D 张量A,其元素全为 1,并且一个包含大小为2的 1D 张量B,其元素全为 0,然后将它们连接成一个大小为5的 1D 张量C

>>> A = torch.ones(3)
>>> B = torch.zeros(2)
>>> C = torch.cat([A, B], axis=0)
>>> print(C)
tensor([1., 1., 1., 0., 0.]) 

如果我们创建了大小为3的 1D 张量AB,那么我们可以将它们堆叠在一起形成一个 2D 张量S

>>> A = torch.ones(3)
>>> B = torch.zeros(3)
>>> S = torch.stack([A, B], axis=1)
>>> print(S)
tensor([[1., 0.],
        [1., 0.],
        [1., 0.]]) 

PyTorch API 具有许多操作,您可以用它们来构建模型、处理数据等。然而,覆盖每个函数超出了本书的范围,我们将专注于最基本的那些。有关所有操作和函数的完整列表,请参阅 PyTorch 文档页面:pytorch.org/docs/stable/index.html

在 PyTorch 中构建输入流水线

当我们训练深度神经网络模型时,通常使用迭代优化算法(例如随机梯度下降)逐步训练模型,正如我们在前几章中所看到的。

正如本章开头所提到的,torch.nn是用于构建神经网络模型的模块。在训练数据集相当小并且可以作为张量直接加载到内存中的情况下,我们可以直接使用这个张量进行训练。然而,在典型的使用情况下,当数据集过大以至于无法完全装入计算机内存时,我们将需要以批次的方式从主存储设备(例如硬盘或固态硬盘)加载数据。此外,我们可能需要构建一个数据处理流水线,对数据应用某些转换和预处理步骤,如均值中心化、缩放或添加噪声,以增强训练过程并防止过拟合。

每次手动应用预处理函数可能会相当繁琐。幸运的是,PyTorch 提供了一个特殊的类来构建高效和方便的预处理流水线。在本节中,我们将看到构建 PyTorch DatasetDataLoader 的不同方法的概述,并实现数据加载、洗牌和分批处理。

从现有张量创建 PyTorch DataLoader

如果数据已经以张量对象、Python 列表或 NumPy 数组的形式存在,我们可以很容易地使用torch.utils.data.DataLoader()类创建数据集加载器。它返回一个DataLoader类的对象,我们可以用它来迭代输入数据集中的各个元素。作为一个简单的例子,考虑下面的代码,它从值为 0 到 5 的列表创建一个数据集:

>>> from torch.utils.data import DataLoader
>>> t = torch.arange(6, dtype=torch.float32)
>>> data_loader = DataLoader(t) 

我们可以轻松地逐个遍历数据集的条目,如下所示:

>>> for item in data_loader:
...     print(item)
tensor([0.])
tensor([1.])
tensor([2.])
tensor([3.])
tensor([4.])
tensor([5.]) 

如果我们希望从该数据集创建批次,批次大小为3,我们可以使用batch_size参数如下进行操作:

>>> data_loader = DataLoader(t, batch_size=3, drop_last=False)
>>> for i, batch in enumerate(data_loader, 1):
...    print(f'batch {i}:', batch)
batch 1: tensor([0., 1., 2.])
batch 2: tensor([3., 4., 5.]) 

这将从该数据集创建两个批次,其中前三个元素进入批次 #1,其余元素进入批次 #2。可选的drop_last参数在张量中的元素数不能被所需批次大小整除时非常有用。我们可以通过将drop_last设置为True来丢弃最后一个不完整的批次。drop_last的默认值为False

我们可以直接迭代数据集,但正如您刚看到的,DataLoader提供了对数据集的自动和可定制的批处理。

将两个张量合并为联合数据集

通常情况下,我们可能有两个(或更多)张量的数据。例如,我们可以有一个特征张量和一个标签张量。在这种情况下,我们需要构建一个结合这些张量的数据集,这将允许我们以元组形式检索这些张量的元素。

假设我们有两个张量,t_xt_y。张量t_x保存我们的特征值,每个大小为3,而t_y存储类标签。对于这个例子,我们首先创建这两个张量如下:

>>> torch.manual_seed(1)
>>> t_x = torch.rand([4, 3], dtype=torch.float32)
>>> t_y = torch.arange(4) 

现在,我们希望从这两个张量创建一个联合数据集。我们首先需要创建一个Dataset类,如下所示:

>>> from torch.utils.data import Dataset
>>> class JointDataset(Dataset):
...     def __init__(self, x, y):
...         self.x = x
...         self.y = y
...        
...     def __len__(self):
...         return len(self.x)
...
...     def __getitem__(self, idx):
...         return self.x[idx], self.y[idx] 

自定义的Dataset类必须包含以下方法,以便稍后由数据加载器使用:

  • __init__(): 这是初始逻辑发生的地方,例如读取现有数组、加载文件、过滤数据等。

  • __getitem__(): 这将返回给定索引的对应样本。

然后,我们使用自定义的Dataset类从t_xt_y创建一个联合数据集,如下所示:

>>> joint_dataset = JointDataset(t_x, t_y) 

最后,我们可以如下打印联合数据集的每个示例:

>>> for example in joint_dataset:
...     print('  x: ', example[0], '  y: ', example[1])
  x:  tensor([0.7576, 0.2793, 0.4031])   y:  tensor(0)
  x:  tensor([0.7347, 0.0293, 0.7999])   y:  tensor(1)
  x:  tensor([0.3971, 0.7544, 0.5695])   y:  tensor(2)
  x:  tensor([0.4388, 0.6387, 0.5247])   y:  tensor(3) 

如果第二个数据集是张量形式的带标签数据集,我们也可以简单地利用torch.utils.data.TensorDataset类。因此,我们可以如下创建一个联合数据集,而不是使用我们自定义的DatasetJointDataset

>>> joint_dataset = JointDataset(t_x, t_y) 

注意,一个常见的错误来源可能是原始特征(x)和标签(y)之间的逐元素对应关系可能会丢失(例如,如果两个数据集分别被洗牌)。然而,一旦它们合并成一个数据集,就可以安全地应用这些操作。

如果我们从磁盘上的图像文件名列表创建了数据集,我们可以定义一个函数来从这些文件名加载图像。您将在本章后面看到将多个转换应用于数据集的示例。

洗牌、批处理和重复

正如第二章中提到的,用于分类的简单机器学习算法的训练,在使用随机梯度下降优化训练 NN 模型时,重要的是以随机打乱的批次方式提供训练数据。您已经看到如何使用数据加载器对象的batch_size参数指定批次大小。现在,除了创建批次之外,您还将看到如何对数据集进行洗牌和重新迭代。我们将继续使用之前的联合数据集。

首先,让我们从joint_dataset数据集创建一个打乱顺序的数据加载器:

>>> torch.manual_seed(1) 
>>> data_loader = DataLoader(dataset=joint_dataset, batch_size=2, shuffle=True) 

在这里,每个批次包含两个数据记录(x)和相应的标签(y)。现在我们逐条通过数据加载器迭代数据入口如下:

>>> for i, batch in enumerate(data_loader, 1):
...     print(f'batch {i}:', 'x:', batch[0],
              '\n         y:', batch[1])
batch 1: x: tensor([[0.4388, 0.6387, 0.5247],
        [0.3971, 0.7544, 0.5695]]) 
         y: tensor([3, 2])
batch 2: x: tensor([[0.7576, 0.2793, 0.4031],
        [0.7347, 0.0293, 0.7999]]) 
         y: tensor([0, 1]) 

行被随机打乱,但不会丢失xy条目之间的一一对应关系。

此外,在训练模型多个 epochs 时,我们需要按所需的 epochs 数量对数据集进行洗牌和迭代。因此,让我们对批处理数据集进行两次迭代:

>>> for epoch in range(2): 
>>>     print(f'epoch {epoch+1}')
>>>     for i, batch in enumerate(data_loader, 1):
...         print(f'batch {i}:', 'x:', batch[0], 
                  '\n         y:', batch[1])
epoch 1
batch 1: x: tensor([[0.7347, 0.0293, 0.7999],
        [0.3971, 0.7544, 0.5695]]) 
         y: tensor([1, 2])
batch 2: x: tensor([[0.4388, 0.6387, 0.5247],
        [0.7576, 0.2793, 0.4031]]) 
         y: tensor([3, 0])
epoch 2
batch 1: x: tensor([[0.3971, 0.7544, 0.5695],
        [0.7576, 0.2793, 0.4031]]) 
         y: tensor([2, 0])
batch 2: x: tensor([[0.7347, 0.0293, 0.7999],
        [0.4388, 0.6387, 0.5247]]) 
         y: tensor([1, 3]) 

这导致了两组不同的批次。在第一个 epoch 中,第一批次包含一对值[y=1, y=2],第二批次包含一对值[y=3, y=0]。在第二个 epoch 中,两个批次分别包含一对值[y=2, y=0][y=1, y=3]。对于每次迭代,批次内的元素也被打乱了。

从本地存储磁盘上的文件创建数据集

在本节中,我们将从存储在磁盘上的图像文件构建数据集。本章的在线内容与一个图像文件夹相关联。下载文件夹后,您应该能够看到六张猫和狗的 JPEG 格式图像。

这个小数据集将展示如何从存储的文件中构建数据集。为此,我们将使用两个额外的模块:PIL中的Image来读取图像文件内容和torchvision中的transforms来解码原始内容并调整图像大小。

PIL.Imagetorchvision.transforms模块提供了许多额外和有用的函数,这超出了本书的范围。建议您浏览官方文档以了解更多有关这些函数的信息:

pillow.readthedocs.io/en/stable/reference/Image.html提供了关于PIL.Image的参考文档

pytorch.org/vision/stable/transforms.html提供了关于torchvision.transforms的参考文档

在我们开始之前,让我们看一下这些文件的内容。我们将使用pathlib库生成一个图像文件列表:

>>> import pathlib
>>> imgdir_path = pathlib.Path('cat_dog_images')
>>> file_list = sorted([str(path) for path in
... imgdir_path.glob('*.jpg')])
>>> print(file_list)
['cat_dog_images/dog-03.jpg', 'cat_dog_images/cat-01.jpg', 'cat_dog_images/cat-02.jpg', 'cat_dog_images/cat-03.jpg', 'cat_dog_images/dog-01.jpg', 'cat_dog_images/dog-02.jpg'] 

接下来,我们将使用 Matplotlib 可视化这些图像示例:

>>> import matplotlib.pyplot as plt
>>> import os
>>> from PIL import Image
>>> fig = plt.figure(figsize=(10, 5))
>>> for i, file in enumerate(file_list):
...     img = Image.open(file)
...     print('Image shape:', np.array(img).shape)
...     ax = fig.add_subplot(2, 3, i+1)
...     ax.set_xticks([]); ax.set_yticks([])
...     ax.imshow(img)
...     ax.set_title(os.path.basename(file), size=15)
>>> plt.tight_layout()
>>> plt.show()
Image shape: (900, 1200, 3)
Image shape: (900, 1200, 3)
Image shape: (900, 1200, 3)
Image shape: (900, 742, 3)
Image shape: (800, 1200, 3)
Image shape: (800, 1200, 3) 

图 12.3显示了示例图像:

图 12.3:猫和狗的图像

仅通过这个可视化和打印的图像形状,我们就能看到这些图像具有不同的长宽比。如果打印这些图像的长宽比(或数据数组形状),您会看到一些图像高 900 像素,宽 1200 像素(900×1200),一些是 800×1200,还有一个是 900×742。稍后,我们将把这些图像预处理到一个统一的尺寸。另一个需要考虑的问题是这些图像的标签是作为它们的文件名提供的。因此,我们从文件名列表中提取这些标签,将标签1分配给狗,标签0分配给猫:

>>> labels = [1 if 'dog' in 
...              os.path.basename(file) else 0
...                      for file in file_list]
>>> print(labels)
[0, 0, 0, 1, 1, 1] 

现在,我们有两个列表:一个是文件名列表(或每个图像的路径),另一个是它们的标签列表。在前一节中,您学习了如何从两个数组创建一个联合数据集。在这里,我们将执行以下操作:

>>> class ImageDataset(Dataset):
...     def __init__(self, file_list, labels):
...         self.file_list = file_list
...         self.labels = labels
... 
...     def __getitem__(self, index):
...         file = self.file_list[index]
...         label = self.labels[index]
...         return file, label
...
...     def __len__(self):
...         return len(self.labels)
>>> image_dataset = ImageDataset(file_list, labels)
>>> for file, label in image_dataset:
...     print(file, label)
cat_dog_images/cat-01.jpg 0
cat_dog_images/cat-02.jpg 0
cat_dog_images/cat-03.jpg 0
cat_dog_images/dog-01.jpg 1
cat_dog_images/dog-02.jpg 1
cat_dog_images/dog-03.jpg 1 

联合数据集具有文件名和标签。

接下来,我们需要对这个数据集应用转换:从文件路径加载图像内容,解码原始内容,并将其调整为所需尺寸,例如 80×120。如前所述,我们使用torchvision.transforms模块将图像调整大小并将加载的像素转换为张量,操作如下:

>>> import torchvision.transforms as transforms 
>>> img_height, img_width = 80, 120
>>> transform = transforms.Compose([
...     transforms.ToTensor(),
...     transforms.Resize((img_height, img_width)),
... ]) 

现在,我们使用刚定义的transform更新ImageDataset类:

>>> class ImageDataset(Dataset):
...     def __init__(self, file_list, labels, transform=None):
...         self.file_list = file_list
...         self.labels = labels
...         self.transform = transform
...
...     def __getitem__(self, index):
...         img = Image.open(self.file_list[index])
...         if self.transform is not None:
...             img = self.transform(img)
...         label = self.labels[index]
...         return img, label
...
...     def __len__(self):
...         return len(self.labels)
>>> 
>>> image_dataset = ImageDataset(file_list, labels, transform) 

最后,我们使用 Matplotlib 可视化这些转换后的图像示例:

>>> fig = plt.figure(figsize=(10, 6))
>>> for i, example in enumerate(image_dataset):
...     ax = fig.add_subplot(2, 3, i+1)
...     ax.set_xticks([]); ax.set_yticks([])
...     ax.imshow(example[0].numpy().transpose((1, 2, 0)))
...     ax.set_title(f'{example[1]}', size=15)
...
>>> plt.tight_layout()
>>> plt.show() 

这导致检索到的示例图像以及它们的标签的以下可视化:

图 12.4:图像带有标签

ImageDataset类中的__getitem__方法将所有四个步骤封装到一个函数中,包括加载原始内容(图像和标签),将图像解码为张量并调整图像大小。然后,该函数返回一个数据集,我们可以通过数据加载器迭代,并应用前面章节中学到的其他操作,如随机排列和分批处理。

torchvision.datasets库获取可用数据集

torchvision.datasets库提供了一组精美的免费图像数据集,用于训练或评估深度学习模型。类似地,torchtext.datasets库提供了用于自然语言的数据集。在这里,我们以torchvision.datasets为例。

torchvision数据集(pytorch.org/vision/stable/datasets.html)的格式很好,并带有信息性的描述,包括特征和标签的格式及其类型和维度,以及数据集的原始来源的链接。另一个优点是这些数据集都是torch.utils.data.Dataset的子类,因此我们在前面章节中涵盖的所有功能都可以直接使用。那么,让我们看看如何在实际中使用这些数据集。

首先,如果您之前没有与 PyTorch 一起安装torchvision,则需要从命令行使用pip安装torchvision库:

pip install torchvision 

您可以查看pytorch.org/vision/stable/datasets.html上的可用数据集列表。

在接下来的段落中,我们将介绍获取两个不同数据集的方法:CelebA (celeb_a)和 MNIST 数字数据集。

让我们首先使用 CelebA 数据集 (mmlab.ie.cuhk.edu.hk/projects/CelebA.html),使用torchvision.datasets.CelebA (pytorch.org/vision/stable/datasets.html#celeba)。torchvision.datasets.CelebA的描述提供了一些有用的信息,帮助我们理解这个数据集的结构:

  • 数据库有三个子集,分别是'train''valid''test'。我们可以通过split参数选择特定的子集或加载它们全部。

  • 图像以PIL.Image格式存储。我们可以使用自定义的transform函数获得变换后的版本,例如transforms.ToTensortransforms.Resize

  • 我们可以使用不同类型的目标,包括'attributes''identity''landmarks''attributes'是图像中人物的 40 个面部属性,例如面部表情、化妆、头发属性等;'identity'是图像的人物 ID;而'landmarks'指的是提取的面部点字典,如眼睛、鼻子等位置。

接下来,我们将调用torchvision.datasets.CelebA类来下载数据,将其存储在指定文件夹中,并将其加载到torch.utils.data.Dataset对象中:

>>> import torchvision 
>>> image_path = './' 
>>> celeba_dataset = torchvision.datasets.CelebA(
...     image_path, split='train', target_type='attr', download=True
... )
1443490838/? [01:28<00:00, 6730259.81it/s]
26721026/? [00:03<00:00, 8225581.57it/s]
3424458/? [00:00<00:00, 14141274.46it/s]
6082035/? [00:00<00:00, 21695906.49it/s]
12156055/? [00:00<00:00, 12002767.35it/s]
2836386/? [00:00<00:00, 3858079.93it/s] 

您可能会遇到BadZipFile: File is not a zip file错误,或者RuntimeError: The daily quota of the file img_align_celeba.zip is exceeded and it can't be downloaded. This is a limitation of Google Drive and can only be overcome by trying again later;这意味着 Google Drive 的每日下载配额已超过 CelebA 文件的大小限制。为了解决这个问题,您可以从源地址手动下载文件:mmlab.ie.cuhk.edu.hk/projects/CelebA.html。在下载的celeba/文件夹中,您可以解压img_align_celeba.zip文件。image_path是下载文件夹celeba/的根目录。如果您已经下载过文件一次,您可以简单地将download=False设置为True。如需更多信息和指导,请查看附带的代码笔记本:github.com/rasbt/machine-learning-book/blob/main/ch12/ch12_part1.ipynb

现在我们已经实例化了数据集,让我们检查对象是否是torch.utils.data.Dataset类:

>>> assert isinstance(celeba_dataset, torch.utils.data.Dataset) 

如前所述,数据集已经分为训练集、测试集和验证集,我们只加载训练集。我们只使用'attributes'目标。为了查看数据示例的外观,我们可以执行以下代码:

>>> example = next(iter(celeba_dataset))
>>> print(example)
(<PIL.JpegImagePlugin.JpegImageFile image mode=RGB size=178x218 at 0x120C6C668>, tensor([0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 1, 0, 1, 0, 1, 0, 0, 1])) 

请注意,此数据集中的样本以 (PIL.Image, attributes) 的元组形式出现。如果我们希望在训练过程中将此数据集传递给监督深度学习模型,我们必须将其重新格式化为 (features tensor, label) 的元组形式。例如,我们将使用属性中的 'Smiling' 类别作为标签,这是第 31 个元素。

最后,让我们从中获取前 18 个示例,以其 'Smiling' 标签可视化它们:

>>> from itertools import islice
>>> fig = plt.figure(figsize=(12, 8))
>>> for i, (image, attributes) in islice(enumerate(celeba_dataset), 18):
...     ax = fig.add_subplot(3, 6, i+1)
...     ax.set_xticks([]); ax.set_yticks([])
...     ax.imshow(image)
...     ax.set_title(f'{attributes[31]}', size=15)
>>> plt.show() 

celeba_dataset 中检索到的示例及其标签显示在 Figure 12.5 中:

一个人的拼贴  自动以低置信度生成的描述

Figure 12.5: 模型预测微笑名人

这就是我们需要做的一切,以获取并使用 CelebA 图像数据集。

接下来,我们将继续使用 torchvision.datasets.MNIST (pytorch.org/vision/stable/datasets.html#mnist) 中的第二个数据集。让我们看看如何使用它来获取 MNIST 手写数字数据集:

  • 数据库有两个分区,分别是 'train''test'。我们需要选择特定的子集进行加载。

  • 图像以 PIL.Image 格式存储。我们可以使用自定义的 transform 函数获取其转换版本,例如 transforms.ToTensortransforms.Resize

  • 目标有 10 个类别,从 09

现在,我们可以下载 'train' 分区,将元素转换为元组,并可视化 10 个示例:

>>> mnist_dataset = torchvision.datasets.MNIST(image_path, 'train', download=True)
>>> assert isinstance(mnist_dataset, torch.utils.data.Dataset)
>>> example = next(iter(mnist_dataset))
>>> print(example)
(<PIL.Image.Image image mode=L size=28x28 at 0x126895B00>, 5)
>>> fig = plt.figure(figsize=(15, 6))
>>> for i, (image, label) in  islice(enumerate(mnist_dataset), 10):
...     ax = fig.add_subplot(2, 5, i+1)
...     ax.set_xticks([]); ax.set_yticks([])
...     ax.imshow(image, cmap='gray_r')
...     ax.set_title(f'{label}', size=15)
>>> plt.show() 

从这个数据集中检索到的示例手写数字如下所示:

Figure 12.6: 正确识别手写数字

这完成了我们关于构建和操作数据集以及从torchvision.datasets库获取数据集的覆盖。接下来,我们将看到如何在 PyTorch 中构建 NN 模型。

在 PyTorch 中构建 NN 模型

在本章中,到目前为止,您已经了解了 PyTorch 的基本实用组件,用于操作张量并将数据组织成可以在训练期间迭代的格式。在本节中,我们将最终在 PyTorch 中实现我们的第一个预测模型。由于 PyTorch 比 scikit-learn 等机器学习库更加灵活但也更加复杂,我们将从一个简单的线性回归模型开始。

PyTorch 神经网络模块(torch.nn)

torch.nn 是一个设计优雅的模块,旨在帮助创建和训练神经网络。它允许在几行代码中轻松进行原型设计和构建复杂模型。

要充分利用该模块的功能,并为您的问题定制它,您需要理解它在做什么。为了发展这种理解,我们将首先在一个玩具数据集上训练一个基本的线性回归模型,而不使用任何来自 torch.nn 模块的特性;我们只会使用基本的 PyTorch 张量操作。

然后,我们将逐步添加来自torch.nntorch.optim的特性。正如您将在接下来的小节中看到的,这些模块使得构建 NN 模型变得极其简单。我们还将利用 PyTorch 中支持的数据集流水线功能,例如DatasetDataLoader,这些您在前一节已经了解过。在本书中,我们将使用torch.nn模块来构建 NN 模型。

在 PyTorch 中构建 NN 的最常用方法是通过nn.Module,它允许将层堆叠起来形成网络。这使我们能够更好地控制前向传播。我们将看到使用nn.Module类构建 NN 模型的示例。

最后,正如您将在接下来的小节中看到的,训练好的模型可以保存并重新加载以供将来使用。

构建线性回归模型

在这个小节中,我们将构建一个简单的模型来解决线性回归问题。首先,让我们在 NumPy 中创建一个玩具数据集并可视化它:

>>> X_train = np.arange(10, dtype='float32').reshape((10, 1))
>>> y_train = np.array([1.0, 1.3, 3.1, 2.0, 5.0, 
...                     6.3, 6.6,7.4, 8.0,
...                     9.0], dtype='float32')
>>> plt.plot(X_train, y_train, 'o', markersize=10)
>>> plt.xlabel('x')
>>> plt.ylabel('y')
>>> plt.show() 

因此,训练样本将如下显示在散点图中:

图 12.7:训练样本的散点图

接下来,我们将标准化特征(平均中心化和除以标准差),并为训练集创建一个 PyTorch 的Dataset及其相应的DataLoader

>>> from torch.utils.data import TensorDataset
>>> X_train_norm = (X_train - np.mean(X_train)) / np.std(X_train)
>>> X_train_norm = torch.from_numpy(X_train_norm)
>>> y_train = torch.from_numpy(y_train)
>>> train_ds = TensorDataset(X_train_norm, y_train)
>>> batch_size = 1
>>> train_dl = DataLoader(train_ds, batch_size, shuffle=True) 

在这里,我们为DataLoader设置了批大小为1

现在,我们可以定义我们的线性回归模型为 z = wx + b。在这里,我们将使用torch.nn模块。它提供了预定义的层用于构建复杂的 NN 模型,但首先,您将学习如何从头开始定义一个模型。在本章的后面,您将看到如何使用这些预定义层。

对于这个回归问题,我们将从头开始定义一个线性回归模型。我们将定义我们模型的参数,weightbias,它们分别对应于权重和偏置参数。最后,我们将定义model()函数来确定这个模型如何使用输入数据生成其输出:

>>> torch.manual_seed(1)
>>> weight = torch.randn(1)
>>> weight.requires_grad_()
>>> bias = torch.zeros(1, requires_grad=True)
>>> def model(xb):
...     return xb @ weight + bias 

定义模型之后,我们可以定义损失函数,以便找到最优模型权重。在这里,我们将选择均方误差MSE)作为我们的损失函数:

>>> def loss_fn(input, target):
...     return (input-target).pow(2).mean() 

此外,为了学习模型的权重参数,我们将使用随机梯度下降。在这个小节中,我们将通过自己实现随机梯度下降过程来训练,但在下一个小节中,我们将使用优化包torch.optim中的SGD方法来做同样的事情。

要实现随机梯度下降算法,我们需要计算梯度。与手动计算梯度不同,我们将使用 PyTorch 的torch.autograd.backward函数。我们将涵盖torch.autograd及其不同的类和函数,用于实现自动微分在第十三章深入探讨 - PyTorch 的机制

现在,我们可以设置学习率并训练模型进行 200 个 epochs。训练模型的代码如下,针对数据集的批处理版本:

>>> learning_rate = 0.001
>>> num_epochs = 200
>>> log_epochs = 10
>>> for epoch in range(num_epochs):
...     for x_batch, y_batch in train_dl:
...         pred = model(x_batch)
...         loss = loss_fn(pred, y_batch)
...         loss.backward()
...     with torch.no_grad():
...         weight -= weight.grad * learning_rate
...         bias -= bias.grad * learning_rate
...         weight.grad.zero_() 
...         bias.grad.zero_()   
...     if epoch % log_epochs==0:
...         print(f'Epoch {epoch}  Loss {loss.item():.4f}')
Epoch 0  Loss 5.1701
Epoch 10  Loss 30.3370
Epoch 20  Loss 26.9436
Epoch 30  Loss 0.9315
Epoch 40  Loss 3.5942
Epoch 50  Loss 5.8960
Epoch 60  Loss 3.7567
Epoch 70  Loss 1.5877
Epoch 80  Loss 0.6213
Epoch 90  Loss 1.5596
Epoch 100  Loss 0.2583
Epoch 110  Loss 0.6957
Epoch 120  Loss 0.2659
Epoch 130  Loss 0.1615
Epoch 140  Loss 0.6025
Epoch 150  Loss 0.0639
Epoch 160  Loss 0.1177
Epoch 170  Loss 0.3501
Epoch 180  Loss 0.3281
Epoch 190  Loss 0.0970 

让我们查看训练好的模型并绘制它。对于测试数据,我们将创建一个在 0 到 9 之间均匀分布的值的 NumPy 数组。由于我们训练模型时使用了标准化特征,我们还将在测试数据上应用相同的标准化:

>>> print('Final Parameters:', weight.item(), bias.item())
Final Parameters:  2.669806480407715 4.879569053649902
>>> X_test = np.linspace(0, 9, num=100, dtype='float32').reshape(-1, 1)
>>> X_test_norm = (X_test - np.mean(X_train)) / np.std(X_train)
>>> X_test_norm = torch.from_numpy(X_test_norm)
>>> y_pred = model(X_test_norm).detach().numpy()
>>> fig = plt.figure(figsize=(13, 5))
>>> ax = fig.add_subplot(1, 2, 1)
>>> plt.plot(X_train_norm, y_train, 'o', markersize=10)
>>> plt.plot(X_test_norm, y_pred, '--', lw=3)
>>> plt.legend(['Training examples', 'Linear reg.'], fontsize=15)
>>> ax.set_xlabel('x', size=15)
>>> ax.set_ylabel('y', size=15)
>>> ax.tick_params(axis='both', which='major', labelsize=15)
>>> plt.show() 

图 12.8显示了训练示例的散点图和训练的线性回归模型:

图 12.8:线性回归模型很好地拟合了数据

通过torch.nntorch.optim模块进行模型训练

在前面的例子中,我们看到如何通过编写自定义损失函数loss_fn()来训练模型,并应用随机梯度下降优化。然而,编写损失函数和梯度更新可能是在不同项目中重复的任务。torch.nn模块提供了一组损失函数,而torch.optim支持大多数常用的优化算法,可以根据计算出的梯度来更新参数。为了看看它们是如何工作的,让我们创建一个新的均方误差(MSE)损失函数和一个随机梯度下降优化器:

>>> import torch.nn as nn
>>> loss_fn = nn.MSELoss(reduction='mean')
>>> input_size = 1
>>> output_size = 1
>>> model = nn.Linear(input_size, output_size)
>>> optimizer = torch.optim.SGD(model.parameters(), lr=learning_rate) 

请注意,这里我们使用torch.nn.Linear类来代替手动定义线性层。

现在,我们可以简单地调用optimizerstep()方法来训练模型。我们可以传递一个批处理的数据集(例如前面例子中创建的train_dl):

>>> for epoch in range(num_epochs):
...     for x_batch, y_batch in train_dl:
...         # 1\. Generate predictions
...         pred = model(x_batch)[:, 0]
...         # 2\. Calculate loss
...         loss = loss_fn(pred, y_batch)
...         # 3\. Compute gradients
...         loss.backward()
...         # 4\. Update parameters using gradients
...         optimizer.step()
...         # 5\. Reset the gradients to zero
...         optimizer.zero_grad()    
...     if epoch % log_epochs==0:
...         print(f'Epoch {epoch}  Loss {loss.item():.4f}') 

模型训练完成后,可视化结果并确保它们与以前方法的结果相似。要获取权重和偏置参数,我们可以执行以下操作:

>>> print('Final Parameters:', model.weight.item(), model.bias.item())
Final Parameters: 2.646660089492798 4.883835315704346 

构建一个用于分类鸢尾花数据集中花朵的多层感知机

在前面的例子中,您看到了如何从头开始构建模型。我们使用随机梯度下降优化来训练这个模型。虽然我们从最简单的可能示例开始我们的旅程,但是你可以看到,即使对于这样一个简单的案例来说,从头定义模型也既不吸引人,也不是良好的实践。相反,PyTorch 通过torch.nn提供了已定义的层,可以直接用作 NN 模型的构建块。在本节中,您将学习如何使用这些层来解决使用鸢尾花数据集(识别三种鸢尾花的物种)的分类任务,并使用torch.nn模块构建一个两层感知机。首先,让我们从sklearn.datasets获取数据:

>>> from sklearn.datasets import load_iris
>>> from sklearn.model_selection import train_test_split 
>>> iris = load_iris()
>>> X = iris['data']
>>> y = iris['target']
>>> X_train, X_test, y_train, y_test = train_test_split(
...    X, y, test_size=1./3, random_state=1) 

在这里,我们随机选择了 100 个样本(2/3)用于训练,以及 50 个样本(1/3)用于测试。

接下来,我们对特征进行标准化(均值中心化并除以标准差),并为训练集创建一个 PyTorch Dataset及其相应的DataLoader

>>> X_train_norm = (X_train - np.mean(X_train)) / np.std(X_train)
>>> X_train_norm = torch.from_numpy(X_train_norm).float()
>>> y_train = torch.from_numpy(y_train) 
>>> train_ds = TensorDataset(X_train_norm, y_train)
>>> torch.manual_seed(1)
>>> batch_size = 2
>>> train_dl = DataLoader(train_ds, batch_size, shuffle=True) 

在这里,我们将DataLoader的批处理大小设置为2

现在,我们准备使用torch.nn模块来高效地构建模型。特别地,使用nn.Module类,我们可以堆叠几层并建立一个神经网络。您可以在pytorch.org/docs/stable/nn.html查看所有已经可用的层列表。对于这个问题,我们将使用Linear层,也被称为全连接层或密集层,可以最好地表示为f(w × x + b),其中x代表包含输入特征的张量,wb是权重矩阵和偏置向量,f是激活函数。

神经网络中的每一层都从前一层接收其输入,因此其维度(秩和形状)是固定的。通常,我们只需要在设计神经网络架构时关注输出的维度。在这里,我们希望定义一个具有两个隐藏层的模型。第一层接收四个特征的输入,并将它们投影到 16 个神经元。第二层接收前一层的输出(其大小为16),并将其投影到三个输出神经元,因为我们有三个类标签。可以通过以下方式实现:

>>> class Model(nn.Module):
...     def __init__(self, input_size, hidden_size, output_size):
...         super().__init__()
...         self.layer1 = nn.Linear(input_size, hidden_size)
...         self.layer2 = nn.Linear(hidden_size, output_size)
...     def forward(self, x):
...         x = self.layer1(x)
...         x = nn.Sigmoid()(x)
...         x = self.layer2(x)
...         x = nn.Softmax(dim=1)(x)
...         return x
>>> input_size = X_train_norm.shape[1]
>>> hidden_size = 16
>>> output_size = 3 
>>> model = Model(input_size, hidden_size, output_size) 

这里,我们在第一层使用了 sigmoid 激活函数,在最后(输出)层使用了 softmax 激活函数。由于我们这里有三个类标签,softmax 激活函数在最后一层用于支持多类分类(这也是为什么输出层有三个神经元)。我们将在本章后面讨论不同的激活函数及其应用。

接下来,我们将损失函数指定为交叉熵损失,并将优化器指定为 Adam:

Adam 优化器是一种强大的基于梯度的优化方法,我们将在第十四章《使用深度卷积神经网络对图像进行分类》中详细讨论。

>>> learning_rate = 0.001
>>> loss_fn = nn.CrossEntropyLoss()
>>> optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate) 

现在,我们可以训练模型了。我们将指定 epoch 数为100。训练花卉分类模型的代码如下:

>>> num_epochs = 100
>>> loss_hist = [0] * num_epochs
>>> accuracy_hist = [0] * num_epochs
>>> for epoch in range(num_epochs):
...     for x_batch, y_batch in train_dl:
...         pred = model(x_batch)
...         loss = loss_fn(pred, y_batch)
...         loss.backward()
...         optimizer.step()
...         optimizer.zero_grad()
...         loss_hist[epoch] += loss.item()*y_batch.size(0)
...         is_correct = (torch.argmax(pred, dim=1) == y_batch).float()
...         accuracy_hist[epoch] += is_correct.mean()
...      loss_hist[epoch] /= len(train_dl.dataset)
...      accuracy_hist[epoch] /= len(train_dl.dataset) 

loss_histaccuracy_hist列表保存了每个 epoch 后的训练损失和训练精度。我们可以使用这些来可视化学习曲线,如下所示:

>>> fig = plt.figure(figsize=(12, 5))
>>> ax = fig.add_subplot(1, 2, 1)
>>> ax.plot(loss_hist, lw=3)
>>> ax.set_title('Training loss', size=15)
>>> ax.set_xlabel('Epoch', size=15)
>>> ax.tick_params(axis='both', which='major', labelsize=15)
>>> ax = fig.add_subplot(1, 2, 2)
>>> ax.plot(accuracy_hist, lw=3)
>>> ax.set_title('Training accuracy', size=15)
>>> ax.set_xlabel('Epoch', size=15)
>>> ax.tick_params(axis='both', which='major', labelsize=15)
>>> plt.show() 

学习曲线(训练损失和训练精度)如下:

图 12.9:训练损失和准确率曲线

在测试数据集上评估训练好的模型。

现在,我们可以评估训练好的模型在测试数据集上的分类准确率了:

>>> X_test_norm = (X_test - np.mean(X_train)) / np.std(X_train)
>>> X_test_norm = torch.from_numpy(X_test_norm).float()
>>> y_test = torch.from_numpy(y_test) 
>>> pred_test = model(X_test_norm)
>>> correct = (torch.argmax(pred_test, dim=1) == y_test).float()
>>> accuracy = correct.mean()
>>> print(f'Test Acc.: {accuracy:.4f}')
Test Acc.: 0.9800 

由于我们使用标准化特征训练了模型,我们也将相同的标准化应用于测试数据。分类准确率为 0.98(即 98%)。

保存和重新加载训练好的模型

训练好的模型可以保存在磁盘上供将来使用。可以通过以下方式实现:

>>> path = 'iris_classifier.pt'
>>> torch.save(model, path) 

调用save(model)会保存模型架构和所有学到的参数。按照一般惯例,我们可以使用'pt''pth'文件扩展名保存模型。

现在,让我们重新加载保存的模型。由于我们已经保存了模型的结构和权重,我们可以只用一行代码轻松重建和重新加载参数:

>>> model_new = torch.load(path) 

尝试通过调用model_new.eval()来验证模型结构:

>>> model_new.eval()
Model(
  (layer1): Linear(in_features=4, out_features=16, bias=True)
  (layer2): Linear(in_features=16, out_features=3, bias=True)
) 

最后,让我们在测试数据集上评估这个重新加载的新模型,以验证结果与之前是否相同:

>>> pred_test = model_new(X_test_norm)
>>> correct = (torch.argmax(pred_test, dim=1) == y_test).float()
>>> accuracy = correct.mean() 
>>> print(f'Test Acc.: {accuracy:.4f}')
Test Acc.: 0.9800 

如果你只想保存已学习的参数,可以像下面这样使用save(model.state_dict())

>>> path = 'iris_classifier_state.pt'
>>> torch.save(model.state_dict(), path) 

要重新加载保存的参数,我们首先需要像之前一样构建模型,然后将加载的参数提供给模型:

>>> model_new = Model(input_size, hidden_size, output_size)
>>> model_new.load_state_dict(torch.load(path)) 

选择多层神经网络的激活函数

简单起见,到目前为止我们只讨论了在多层前馈神经网络中使用的 S 型激活函数;我们在 MLP 实现的隐藏层和输出层都使用了它(第十一章)。

请注意,在本书中,逻辑函数,,因其简洁性常被称为sigmoid函数,在机器学习文献中很常见。在接下来的小节中,您将学习更多关于实现多层神经网络时有用的替代非线性函数的内容。

从技术上讲,我们可以在多层神经网络中使用任何可微函数作为激活函数。我们甚至可以使用线性激活函数,例如在 Adaline(第二章用于分类的简单机器学习算法)中。然而,在实践中,对于隐藏层和输出层都使用线性激活函数并不是很有用,因为我们想要在典型的人工神经网络中引入非线性,以便能够解决复杂问题。多个线性函数的总和毕竟会产生一个线性函数。

我们在第十一章中使用的逻辑(S 型)激活函数可能最接近大脑中神经元的概念——我们可以将其视为神经元是否触发的概率。然而,如果输入非常负,则逻辑(S 型)激活函数的输出会接近于零。如果逻辑函数返回接近于零的输出,在训练过程中神经网络将学习速度非常慢,并且更容易陷入损失地形的局部最小值中。这就是为什么人们通常更喜欢在隐藏层中使用双曲正切作为激活函数的原因。

在讨论双曲正切函数的外观之前,让我们简要回顾一下逻辑函数的一些基础知识,并查看一个使其在多标签分类问题中更有用的泛化。

逻辑函数回顾

正如在本节的介绍中提到的,逻辑函数实际上是 S 形函数的一种特殊情况。您可以从第三章《使用 Scikit-Learn 进行机器学习分类器之旅》中的逻辑回归部分回忆起,我们可以使用逻辑函数来建模样本 x 属于正类(类 1)的概率。

给定的净输入,z,如下方程所示:

逻辑(sigmoid)函数将计算如下:

注意 w[0] 是偏置单元(y-轴截距,这意味着 x[0] = 1)。为了提供一个更具体的示例,让我们来看一个二维数据点 x 的模型,以及分配给 w 向量的以下权重系数:

>>> import numpy as np
>>> X = np.array([1, 1.4, 2.5]) ## first value must be 1
>>> w = np.array([0.4, 0.3, 0.5])
>>> def net_input(X, w):
...     return np.dot(X, w)
>>> def logistic(z):
...     return 1.0 / (1.0 + np.exp(-z))
>>> def logistic_activation(X, w):
...     z = net_input(X, w)
...     return logistic(z)
>>> print(f'P(y=1|x) = {logistic_activation(X, w):.3f}')
P(y=1|x) = 0.888 

如果我们计算净输入(z)并使用它来激活具有特定特征值和权重系数的逻辑神经元,则得到一个值为 0.888,我们可以将其解释为这个特定样本 x 属于正类的概率为 88.8%。

第十一章 中,我们使用一热编码技术来表示多类别的真实标签,并设计了包含多个逻辑激活单元的输出层。然而,正如以下代码示例所示,由多个逻辑激活单元组成的输出层并不产生有意义的可解释概率值:

>>> # W : array with shape = (n_output_units, n_hidden_units+1)
>>> #     note that the first column are the bias units
>>> W = np.array([[1.1, 1.2, 0.8, 0.4],
...               [0.2, 0.4, 1.0, 0.2],
...               [0.6, 1.5, 1.2, 0.7]])
>>> # A : data array with shape = (n_hidden_units + 1, n_samples)
>>> #     note that the first column of this array must be 1
>>> A = np.array([[1, 0.1, 0.4, 0.6]])
>>> Z = np.dot(W, A[0])
>>> y_probas = logistic(Z)
>>> print('Net Input: \n', Z)
Net Input:
[1.78  0.76  1.65]
>>> print('Output Units:\n', y_probas)
Output Units:
[ 0.85569687  0.68135373  0.83889105] 

正如您在输出中所看到的,得到的值不能被解释为三类问题的概率。其原因在于它们不会加总到 1。然而,如果我们仅使用模型来预测类别标签而不是类别成员概率,这实际上并不是一个大问题。从之前获得的输出单元预测类别标签的一种方法是使用最大值:

>>> y_class = np.argmax(Z, axis=0)
>>> print('Predicted class label:', y_class) 
Predicted class label: 0 

在某些情境中,计算多类预测的有意义的类别概率可能会有所帮助。在下一节中,我们将看看逻辑函数的一般化,即 softmax 函数,它可以帮助我们完成这项任务。

通过 softmax 函数估计多类分类中的类别概率

在上一节中,您看到我们如何使用 argmax 函数获得类标签。在构建用于在鸢尾花数据集中分类花卉的多层感知机部分中,我们确定在 MLP 模型的最后一层使用 activation='softmax'softmax 函数是 argmax 函数的一种软形式;它不仅提供单一类别索引,还提供每个类别的概率。因此,它允许我们在多类别设置(多项逻辑回归)中计算有意义的类别概率。

softmax中,特定样本的概率,具有净输入z属于第i类,可以通过分母中的归一化项来计算,即指数加权线性函数的总和:

要看softmax如何发挥作用,让我们在 Python 中编码它:

>>> def softmax(z):
...     return np.exp(z) / np.sum(np.exp(z))
>>> y_probas = softmax(Z)
>>> print('Probabilities:\n', y_probas)
Probabilities:
[ 0.44668973  0.16107406  0.39223621]
>>> np.sum(y_probas)
1.0 

如您所见,预测的类别概率现在总和为 1,符合我们的预期。值得注意的是,预测的类别标签与我们对逻辑输出应用argmax函数时相同。

可能有助于将softmax函数的结果视为在多类别设置中获取有意义的类成员预测的归一化输出。因此,当我们在 PyTorch 中构建多类别分类模型时,我们可以使用torch.softmax()函数来估计每个类别成员的概率,以查看我们如何在下面的代码中使用torch.softmax()激活函数,我们将Z转换为一个张量,并额外保留一个维度用于批处理大小:

>>> torch.softmax(torch.from_numpy(Z), dim=0)
tensor([0.4467, 0.1611, 0.3922], dtype=torch.float64) 

使用双曲正切扩展输出光谱

在人工神经网络的隐藏层中经常使用的另一个 Sigmoid 函数是双曲正切(通常称为tanh),可以解释为逻辑函数的重新缩放版本:

双曲正切函数相比逻辑函数的优势在于其具有更广的输出光谱,范围在开区间(–1, 1),这可以提高反向传播算法的收敛性(《神经网络模式识别》C. M. Bishop牛津大学出版社,页码:500-501,1995)。

相比之下,逻辑函数返回一个在开区间(0, 1)内的输出信号。为了简单比较逻辑函数和双曲正切函数,让我们绘制这两个 Sigmoid 函数:

>>> import matplotlib.pyplot as plt
>>> def tanh(z):
...     e_p = np.exp(z)
...     e_m = np.exp(-z)
...     return (e_p - e_m) / (e_p + e_m)
>>> z = np.arange(-5, 5, 0.005)
>>> log_act = logistic(z)
>>> tanh_act = tanh(z)
>>> plt.ylim([-1.5, 1.5])
>>> plt.xlabel('net input $z$')
>>> plt.ylabel('activation $\phi(z)$')
>>> plt.axhline(1, color='black', linestyle=':')
>>> plt.axhline(0.5, color='black', linestyle=':')
>>> plt.axhline(0, color='black', linestyle=':')
>>> plt.axhline(-0.5, color='black', linestyle=':')
>>> plt.axhline(-1, color='black', linestyle=':')
>>> plt.plot(z, tanh_act,
...          linewidth=3, linestyle='--',
...          label='tanh')
>>> plt.plot(z, log_act,
...          linewidth=3,
...          label='logistic')
>>> plt.legend(loc='lower right')
>>> plt.tight_layout()
>>> plt.show() 

如您所见,两个 Sigmoid 曲线的形状看起来非常相似;然而,tanh函数的输出空间是logistic函数的两倍:

图 12.10:双曲正切和逻辑函数的比较

请注意,我们之前详细实现了逻辑和双曲正切函数,仅用于说明目的。实际上,我们可以使用 NumPy 的tanh函数。

或者,在构建一个 NN 模型时,我们可以在 PyTorch 中使用torch.tanh(x)来实现相同的结果:

>>> np.tanh(z)
array([-0.9999092 , -0.99990829, -0.99990737, ...,  0.99990644,
        0.99990737,  0.99990829])
>>> torch.tanh(torch.from_numpy(z))
tensor([-0.9999, -0.9999, -0.9999,  ...,  0.9999,  0.9999,  0.9999],
       dtype=torch.float64) 

此外,逻辑函数在 SciPy 的special模块中可用:

>>> from scipy.special import expit
>>> expit(z)
array([0.00669285, 0.00672617, 0.00675966, ..., 0.99320669, 0.99324034,
       0.99327383]) 

类似地,我们可以在 PyTorch 中使用torch.sigmoid()函数执行相同的计算,如下所示:

>>> torch.sigmoid(torch.from_numpy(z))
tensor([0.0067, 0.0067, 0.0068,  ..., 0.9932, 0.9932, 0.9933],
       dtype=torch.float64) 

请注意,使用torch.sigmoid(x)产生的结果等同于torch.nn.Sigmoid()(x),我们之前使用过。torch.nn.Sigmoid是一个类,您可以通过传递参数来构建一个对象以控制其行为。相比之下,torch.sigmoid是一个函数。

激活函数 ReLU(Rectified linear unit activation)

修正线性单元ReLU)是另一种经常在深度神经网络中使用的激活函数。在深入研究 ReLU 之前,我们应该退后一步,了解 tanh 和逻辑激活函数的梯度消失问题。

要理解这个问题,让我们假设我们最初有净输入 z[1] = 20,这变成 z[2] = 25. 计算双曲正切激活函数时,我们得到 ,显示输出没有变化(由于双曲正切函数的渐近行为和数值误差)。

这意味着激活函数对净输入的导数随着 z 变大而减小。因此,在训练阶段学习权重变得非常缓慢,因为梯度项可能非常接近零。ReLU 激活解决了这个问题。数学上,ReLU 定义如下:

ReLU 仍然是一个非线性函数,非常适合用于学习具有复杂功能的神经网络。除此之外,对 ReLU 的导数,关于其输入,对于正输入值始终为 1。因此,它解决了梯度消失的问题,使其非常适合深度神经网络。在 PyTorch 中,我们可以如下应用 ReLU 激活 torch.relu()

>>> torch.relu(torch.from_numpy(z))
tensor([0.0000, 0.0000, 0.0000,  ..., 4.9850, 4.9900, 4.9950],
       dtype=torch.float64) 

在下一章中,我们将作为多层卷积神经网络的激活函数使用 ReLU 激活函数。

现在我们对人工神经网络中常用的不同激活函数有了更多了解,让我们总结一下本书中迄今为止遇到的不同激活函数:

Table  Description automatically generated

图 12.11:本书涵盖的激活函数

您可以在 pytorch.org/docs/stable/nn.functional.html#non-linear-activation-functions 找到torch.nn 模块中所有可用的激活函数列表。

概要

在本章中,您学习了如何使用 PyTorch,一个用于数值计算的开源库,专注于深度学习。虽然 PyTorch 使用起来比 NumPy 更不方便,因为它增加了支持 GPU 的复杂性,但它允许我们定义和高效训练大型、多层次的神经网络。

此外,您学习了如何使用 torch.nn 模块来构建复杂的机器学习和神经网络模型,并高效地运行它们。我们通过基本的 PyTorch 张量功能从零开始定义了一个模型来探索模型构建。当我们必须在矩阵-向量乘法的水平上编程并定义每个操作的每个细节时,实现模型可能会很乏味。然而,优点在于这允许我们作为开发者结合这些基本操作并构建更复杂的模型。然后,我们探索了torch.nn,这使得构建神经网络模型比从头开始实现它们要容易得多。

最后,您了解了不同的激活函数,并理解了它们的行为和应用。特别是在本章中,我们涵盖了 tanh、softmax 和 ReLU。

在下一章中,我们将继续我们的旅程,并深入研究 PyTorch,我们将与 PyTorch 计算图和自动微分包一起工作。沿途您将学习许多新概念,如梯度计算。

加入我们书籍的 Discord 空间

加入本书的 Discord 工作区,每月举行一次问答环节,与作者亲密互动:

packt.link/MLwPyTorch