) -∑ j=1 m w i,j x (j) 2 subject to w i,j = 0 if x (j) is not one of the k n.n. of x (i) ∑ j=1 m w i,j = 1 for i = 1 , 2 , ⋯ , m
在这一步之后,权重矩阵 (包含权重 )编码了训练实例之间的局部线性关系。第二步是将训练实例映射到一个 d 维空间(其中 d < n),同时尽可能保持这些局部关系。如果 z^((i)) 是在这个 d 维空间中 x^((i)) 的映像,那么我们希望 z^((i)) 和 之间的平方距离尽可能小。这个想法导致了方程 8-5 中描述的无约束优化问题。它看起来与第一步非常相似,但是不是保持实例固定并找到最佳权重,而是相反的:保持权重固定并找到实例映像在低维空间中的最佳位置。注意,Z 是包含所有 z^((i)) 的矩阵。
方程 8-5. LLE 步骤 2:在保持关系的同时降低维度
Scikit-Learn 的 LLE 实现具有以下计算复杂度:O(m log(m)n log(k))用于查找k个最近邻居,O(mnk³)用于优化权重,O(dm²)用于构建低维表示。不幸的是,最后一项中的m²使得这个算法在处理非常大的数据集时效率低下。
正如您所看到的,LLE 与投影技术有很大不同,它显著更复杂,但也可以构建更好的低维表示,特别是在数据是非线性的情况下。
其他降维技术
在我们结束本章之前,让我们快速看一下 Scikit-Learn 中提供的其他几种流行的降维技术:
sklearn.manifold.MDS
多维缩放(MDS)在尝试保持实例之间的距离的同时降低维度。随机投影可以用于高维数据,但在低维数据上效果不佳。
sklearn.manifold.Isomap
Isomap通过将每个实例连接到其最近邻来创建图,然后在尝试保持实例之间的测地距离的同时降低维度。图中两个节点之间的测地距离是这两个节点之间最短路径上的节点数。
sklearn.manifold.TSNE
t-分布随机邻域嵌入(t-SNE)在尝试保持相似实例接近和不相似实例分开的同时降低维度。它主要用于可视化,特别是用于在高维空间中可视化实例的聚类。例如,在本章末尾的练习中,您将使用 t-SNE 来可视化 MNIST 图像的 2D 地图。
sklearn.discriminant_analysis.LinearDiscriminantAnalysis
线性判别分析(LDA)是一种线性分类算法,在训练过程中学习类别之间最具区分性的轴。然后可以使用这些轴来定义一个超平面,将数据投影到该超平面上。这种方法的好处是投影将尽可能地使类别保持分开,因此 LDA 是在运行另一个分类算法之前降低维度的好技术(除非仅使用 LDA 就足够)。
图 8-11 展示了 MDS、Isomap 和 t-SNE 在瑞士卷上的结果。MDS 成功将瑞士卷展平而不丢失其全局曲率,而 Isomap 则完全丢失了曲率。根据下游任务的不同,保留大尺度结构可能是好事或坏事。t-SNE 在展平瑞士卷方面做得相当不错,保留了一些曲率,并且还放大了聚类,将卷撕裂开。同样,这可能是好事或坏事,取决于下游任务。
![mls3 0811]()
图 8-11。使用各种技术将瑞士卷降维为 2D
练习
-
降低数据集维度的主要动机是什么?主要缺点是什么?
-
维度诅咒是什么?
-
一旦数据集的维度降低了,是否可以反转操作?如果可以,如何操作?如果不行,为什么?
-
PCA 能用于降低高度非线性数据集的维度吗?
-
假设您对一个 1000 维数据集执行 PCA,将解释的方差比设置为 95%。结果数据集将有多少维度?
-
在什么情况下会使用常规 PCA、增量 PCA、随机 PCA 或随机投影?
-
如何评估数据集上降维算法的性能?
-
链两种不同的降维算法有意义吗?
-
加载 MNIST 数据集(在第三章介绍)并将其分为训练集和测试集(取前 60000 个实例进行训练,剩下的 10000 个进行测试)。在数据集上训练一个随机森林分类器并计时,然后评估测试集上的结果模型。接下来,使用 PCA 降低数据集的维度,解释方差比为 95%。在降维后的数据集上训练一个新的随机森林分类器并查看所需时间。训练速度快了吗?接下来,在测试集上评估分类器。与之前的分类器相比如何?再尝试使用SGDClassifier。现在 PCA 有多大帮助?
-
使用 t-SNE 将 MNIST 数据集的前 5000 个图像降至 2 维,并使用 Matplotlib 绘制结果。您可以使用散点图,使用 10 种不同的颜色表示每个图像的目标类别。或者,您可以用散点图中的每个点替换为相应实例的类别(从 0 到 9 的数字),甚至绘制数字图像本身的缩小版本(如果绘制所有数字,可视化将太混乱,因此您应该绘制一个随机样本或仅在没有其他实例已经绘制在附近距离的情况下绘制一个实例)。您应该得到一个具有良好分离的数字簇的漂亮可视化效果。尝试使用其他降维算法,如 PCA、LLE 或 MDS,并比较结果的可视化效果。
这些练习的解决方案可在本章笔记本的末尾找到,网址为https://homl.info/colab3。
([1])嗯,如果计算时间,那就是四维,如果你是弦理论学家,那就更多了。
([2])在https://homl.info/30观看一个在 3D 空间中投影的旋转四维立方体。图片由维基百科用户 NerdBoy1392 提供(知识共享署名-相同方式共享 3.0)。转载自https://en.wikipedia.org/wiki/Tesseract。
([3])有趣的事实:如果考虑足够多的维度,你认识的任何人可能在至少一个维度上是极端的(例如,他们在咖啡中放多少糖)。
([4])Karl Pearson,“关于空间中点系统的最佳拟合线和平面”,伦敦、爱丁堡和都柏林哲学杂志和科学杂志 2,第 11 号(1901 年):559-572。
([5])Scikit-Learn 使用 David A. Ross 等人描述的算法,“用于稳健视觉跟踪的增量学习”,国际计算机视觉杂志 77,第 1-3 号(2008 年):125-141。
([6])ε是希腊字母ε,通常用于表示微小值。
([7])Sanjoy Dasgupta 等人,“一个基本计算问题的神经算法”,Science 358,第 6364 号(2017 年):793-796。
([8])Sam T. Roweis 和 Lawrence K. Saul,“通过局部线性嵌入进行非线性降维”,Science 290,第 5500 号(2000 年):2323-2326。
第九章:无监督学习技术
尽管今天大多数机器学习应用都是基于监督学习的(因此,这也是大部分投资的方向),但绝大多数可用数据是无标签的:我们有输入特征X,但没有标签y。计算机科学家 Yann LeCun 曾经说过,“如果智能是一个蛋糕,无监督学习就是蛋糕,监督学习就是蛋糕上的糖衣,而强化学习就是蛋糕上的樱桃。”换句话说,无监督学习中有巨大的潜力,我们只是刚刚开始探索。
假设你想创建一个系统,该系统将拍摄制造生产线上每个物品的几张照片,并检测哪些物品有缺陷。你可以相当容易地创建一个系统,自动拍照,这可能每天给你数千张照片。然后你可以在短短几周内构建一个相当大的数据集。但是等等,没有标签!如果你想训练一个普通的二元分类器,预测物品是否有缺陷,你需要将每张图片标记为“有缺陷”或“正常”。这通常需要人类专家坐下来手动查看所有图片。这是一项漫长、昂贵和繁琐的任务,因此通常只会在可用图片的一小部分上进行。结果,标记的数据集将非常小,分类器的性能将令人失望。此外,每当公司对其产品进行任何更改时,整个过程都需要从头开始。如果算法只能利用无标签数据而无需人类标记每张图片,那不是很好吗?这就是无监督学习的作用。
在第八章中,我们看了最常见的无监督学习任务:降维。在本章中,我们将看一些更多的无监督任务:
聚类
目标是将相似的实例分组到簇中。聚类是数据分析、客户细分、推荐系统、搜索引擎、图像分割、半监督学习、降维等领域的重要工具。
异常检测(也称为离群检测)
目标是学习“正常”数据的外观,然后利用它来检测异常实例。这些实例被称为异常值或离群值,而正常实例被称为内群值。异常检测在各种应用中非常有用,如欺诈检测、制造中检测有缺陷的产品、识别时间序列中的新趋势,或在训练另一个模型之前从数据集中去除离群值,这可以显著提高结果模型的性能。
密度估计
这是估计生成数据集的随机过程的概率密度函数(PDF)的任务。密度估计通常用于异常检测:位于非常低密度区域的实例可能是异常值。它还可用于数据分析和可视化。
准备好享用蛋糕了吗?我们将从两个聚类算法k-means 和 DBSCAN 开始,然后讨论高斯混合模型,看看它们如何用于密度估计、聚类和异常检测。
聚类算法:k 均值和 DBSCAN
当您在山上徒步时,偶然发现了一种您以前从未见过的植物。您四处看看,发现了更多。它们并不完全相同,但它们足够相似,让您知道它们很可能属于同一物种(或至少同一属)。您可能需要植物学家告诉您那是什么物种,但您肯定不需要专家来识别相似外观的对象组。这就是聚类:识别相似实例并将它们分配到集群或相似实例组的任务。
就像分类一样,每个实例被分配到一个组。但是,与分类不同,聚类是一个无监督的任务。考虑图 9-1:左边是鸢尾花数据集(在第四章介绍),其中每个实例的物种(即其类)用不同的标记表示。这是一个带标签的数据集,适合使用逻辑回归、SVM 或随机森林分类器等分类算法。右边是相同的数据集,但没有标签,因此您不能再使用分类算法。这就是聚类算法介入的地方:其中许多算法可以轻松检测到左下角的集群。这也很容易用我们的眼睛看到,但上右角的集群由两个不同的子集群组成并不那么明显。也就是说,数据集有两个额外的特征(萼片长度和宽度)在这里没有表示,聚类算法可以很好地利用所有特征,因此实际上它们相当好地识别了三个集群(例如,使用高斯混合模型,150 个实例中只有 5 个被分配到错误的集群)。
![mls3 0901]()
图 9-1. 分类(左)与聚类(右)
聚类在各种应用中使用,包括:
客户细分
您可以根据客户的购买和在您网站上的活动对其进行聚类。这有助于了解您的客户是谁以及他们需要什么,这样您就可以根据每个细分调整您的产品和营销活动。例如,客户细分在推荐系统中可能很有用,以建议其他同一集群中的用户喜欢的内容。
数据分析
当您分析新数据集时,运行聚类算法然后分析每个集群单独可能会有所帮助。
降维
一旦数据集被聚类,通常可以测量每个实例与每个集群的亲和力;亲和力是衡量实例适合集群程度的任何度量。然后,每个实例的特征向量x可以用其集群亲和力的向量替换。如果有k个集群,那么这个向量是k维的。新向量通常比原始特征向量低得多,但它可以保留足够的信息供进一步处理。
特征工程
集群亲和力通常作为额外特征是有用的。例如,我们在第二章中使用k-means 将地理集群亲和特征添加到加利福尼亚住房数据集中,这有助于我们获得更好的性能。
异常检测(也称为离群值检测)
任何对所有集群的亲和力较低的实例可能是异常值。例如,如果您根据网站用户的行为对其进行了聚类,您可以检测到具有异常行为的用户,例如每秒请求的异常数量。
半监督学习
如果您只有少量标签,您可以执行聚类并将标签传播到同一集群中的所有实例。这种技术可以大大增加后续监督学习算法可用的标签数量,从而提高其性能。
搜索引擎
一些搜索引擎允许您搜索与参考图像相似的图像。要构建这样一个系统,您首先需要将聚类算法应用于数据库中的所有图像;相似的图像将最终位于同一聚类中。然后,当用户提供一个参考图像时,您只需要使用训练过的聚类模型来找到这个图像的聚类,然后简单地返回该聚类中的所有图像。
图像分割
通过根据它们的颜色对像素进行聚类,然后用其聚类的平均颜色替换每个像素的颜色,可以大大减少图像中不同颜色的数量。图像分割在许多对象检测和跟踪系统中使用,因为它使得更容易检测每个对象的轮廓。
对于聚类的定义并没有普遍的定义:它真的取决于上下文,不同的算法将捕捉不同类型的聚类。一些算法寻找围绕特定点集中的实例,称为中心点。其他算法寻找连续的密集实例区域:这些聚类可以采用任何形状。一些算法是分层的,寻找聚类的聚类。等等。
在本节中,我们将介绍两种流行的聚类算法,k-均值和 DBSCAN,并探讨它们的一些应用,如非线性降维、半监督学习和异常检测。
k-均值
考虑在图 9-2 中表示的无标签数据集:您可以清楚地看到五个实例的聚类。k-均值算法是一种简单的算法,能够非常快速和高效地对这种数据集进行聚类,通常只需几次迭代。它是由贝尔实验室的斯图尔特·劳埃德(Stuart Lloyd)在 1957 年提出的,作为脉冲编码调制的技术,但直到 1982 年才在公司外部发表。在 1965 年,爱德华·W·福吉(Edward W. Forgy)几乎发表了相同的算法,因此k-均值有时被称为劳埃德-福吉算法。
![mls3 0902]()
图 9-2。由五个实例聚类组成的无标签数据集
让我们在这个数据集上训练一个k-均值聚类器。它将尝试找到每个聚类的中心,并将每个实例分配给最近的聚类:
from sklearn.cluster import KMeans
from sklearn.datasets import make_blobs
X, y = make_blobs([...]) # make the blobs: y contains the cluster IDs, but we
# will not use them; that's what we want to predict
k = 5
kmeans = KMeans(n_clusters=k, random_state=42)
y_pred = kmeans.fit_predict(X)
请注意,您必须指定算法必须找到的聚类数k。在这个例子中,从数据看,k应该设置为 5 是相当明显的,但一般来说并不那么容易。我们将很快讨论这个问题。
每个实例将被分配到五个聚类中的一个。在聚类的上下文中,实例的标签是算法分配给该实例的聚类的索引;这不应与分类中使用的类标签混淆(请记住,聚类是一种无监督学习任务)。KMeans实例保留了它训练过的实例的预测标签,可以通过labels_实例变量获得:
>>> y_pred
array([4, 0, 1, ..., 2, 1, 0], dtype=int32)
>>> y_pred is kmeans.labels_
True
我们还可以看一下算法找到的五个中心点:
>>> kmeans.cluster_centers_
array([[-2.80389616, 1.80117999],
[ 0.20876306, 2.25551336],
[-2.79290307, 2.79641063],
[-1.46679593, 2.28585348],
[-2.80037642, 1.30082566]])
您可以轻松地将新实例分配给最接近的中心点的聚类:
>>> import numpy as np
>>> X_new = np.array([[0, 2], [3, 2], [-3, 3], [-3, 2.5]])
>>> kmeans.predict(X_new)
array([1, 1, 2, 2], dtype=int32)
如果绘制聚类的决策边界,您将得到一个 Voronoi 图:请参见图 9-3,其中每个中心点用 X 表示。
![mls3 0903]()
图 9-3。k-均值决策边界(Voronoi 图)
绝大多数实例明显被分配到了适当的聚类中,但有一些实例可能被错误标记,特别是在左上角聚类和中心聚类之间的边界附近。事实上,当聚类的直径差异很大时,k-均值算法的表现并不好,因为在将实例分配给聚类时,它只关心到中心点的距离。
与将每个实例分配到单个簇不同,即所谓的硬聚类,给每个实例分配每个簇的分数可能很有用,即所谓的软聚类。分数可以是实例与质心之间的距离或相似度分数(或亲和度),例如我们在第二章中使用的高斯径向基函数。在KMeans类中,transform()方法测量每个实例到每个质心的距离:
>>> kmeans.transform(X_new).round(2)
array([[2.81, 0.33, 2.9 , 1.49, 2.89],
[5.81, 2.8 , 5.85, 4.48, 5.84],
[1.21, 3.29, 0.29, 1.69, 1.71],
[0.73, 3.22, 0.36, 1.55, 1.22]])
在这个例子中,X_new中的第一个实例距离第一个质心约为 2.81,距离第二个质心约为 0.33,距离第三个质心约为 2.90,距离第四个质心约为 1.49,距离第五个质心约为 2.89。如果你有一个高维数据集,并以这种方式进行转换,你最终会得到一个k维的数据集:这种转换可以是一种非常有效的非线性降维技术。或者,你可以使用这些距离作为额外特征来训练另一个模型,就像第二章中那样。
k 均值算法
那么,算法是如何工作的呢?假设你已经得到了质心。你可以通过将每个实例分配到最接近的质心的簇中来轻松地为数据集中的所有实例打上标签。相反,如果你已经得到了所有实例的标签,你可以通过计算该簇中实例的平均值来轻松地找到每个簇的质心。但是你既没有标签也没有质心,那么你该如何继续呢?首先随机放置质心(例如,通过从数据集中随机选择k个实例并使用它们的位置作为质心)。然后给实例打标签,更新质心,给实例打标签,更新质心,依此类推,直到质心不再移动。该算法保证会在有限步数内收敛(通常非常小)。这是因为实例与最接近的质心之间的均方距离在每一步都只能减小,而且由于它不能为负,因此保证会收敛。
你可以在图 9-4 中看到算法的运行过程:质心被随机初始化(左上角),然后实例被标记(右上角),然后质心被更新(左中),实例被重新标记(右中),依此类推。正如你所看到的,在仅三次迭代中,算法已经达到了一个看似接近最优的聚类。
注意
该算法的计算复杂度通常与实例数m、簇数k和维度数n成线性关系。然而,这仅在数据具有聚类结构时才成立。如果没有,那么在最坏情况下,复杂度可能会随实例数呈指数增长。在实践中,这种情况很少发生,k-均值通常是最快的聚类算法之一。
![mls3 0904]()
图 9-4。k-均值算法
尽管算法保证会收敛,但它可能不会收敛到正确的解决方案(即可能会收敛到局部最优解):它是否这样做取决于质心初始化。如果在随机初始化步骤中不幸的话,算法可能会收敛到两种次优解决方案,如图 9-5 所示。
![mls3 0905]()
图 9-5。由于不幸的质心初始化而导致次优解决方案
让我们看看通过改进质心初始化方式来减轻这种风险的几种方法。
质心初始化方法
如果你大致知道质心应该在哪里(例如,如果你之前运行过另一个聚类算法),那么你可以将init超参数设置为包含质心列表的 NumPy 数组,并将n_init设置为1:
good_init = np.array([[-3, 3], [-3, 2], [-3, 1], [-1, 2], [0, 2]])
kmeans = KMeans(n_clusters=5, init=good_init, n_init=1, random_state=42)
kmeans.fit(X)
另一个解决方案是使用不同的随机初始化多次运行算法,并保留最佳解决方案。随机初始化的次数由n_init超参数控制:默认情况下等于10,这意味着当您调用fit()时,先前描述的整个算法会运行 10 次,Scikit-Learn 会保留最佳解决方案。但它究竟如何知道哪个解决方案最佳呢?它使用性能指标!该指标称为模型的惯性,它是实例与其最近质心之间的平方距离之和。对于左侧的模型,它大约等于 219.4,在图 9-5 中的右侧模型为 258.6,在图 9-3 中的模型仅为 211.6。KMeans类运行算法n_init次,并保留具有最低惯性的模型。在这个例子中,将选择图 9-3 中的模型(除非我们在n_init连续的随机初始化中非常不幸)。如果您感兴趣,可以通过inertia_实例变量访问模型的惯性:
>>> kmeans.inertia_
211.59853725816836
score()方法返回负惯性(它是负数,因为预测器的score()方法必须始终遵守 Scikit-Learn 的“分数越高越好”的规则:如果一个预测器比另一个更好,则其score()方法应返回更高的分数):
>>> kmeans.score(X)
-211.5985372581684
k-means 算法的一个重要改进k-means++是由 David Arthur 和 Sergei Vassilvitskii 在2006 年的一篇论文中提出的。他们引入了一个更智能的初始化步骤,倾向于选择彼此相距较远的质心,这一改进使k-means 算法更不可能收敛到次优解。该论文表明,为更智能的初始化步骤所需的额外计算是非常值得的,因为这使得可以大大减少运行算法以找到最佳解决方案的次数。k-means++初始化算法的工作方式如下:
-
从数据集中均匀随机选择一个质心c^((1))。
-
选择一个新的质心c((*i*)),以概率选择实例**x**((i)) / 的概率,其中 D(x((*i*)))是实例**x**((i))与已选择的最近质心之间的距离。这种概率分布确保距离已选择的质心更远的实例更有可能被选择为质心。
-
重复上一步,直到所有k个质心都被选择。
KMeans类默认使用此初始化方法。
加速的 k 均值和小批量 k 均值
另一个对k-means 算法的改进是由 Charles Elkan 在2003 年的一篇论文中提出的。在一些具有许多簇的大型数据集上,通过避免许多不必要的距离计算,可以加速算法。Elkan 通过利用三角不等式(即,直线始终是两点之间的最短距离^(4)并跟踪实例和质心之间的距离的下限和上限来实现这一点。但是,Elkan 的算法并不总是加速训练,有时甚至会显著减慢训练速度;这取决于数据集。但是,如果您想尝试一下,请设置algorithm="elkan"。
k-means 算法的另一个重要变体是由 David Sculley 在2010 年的一篇论文中提出的。⁵ 该算法不是在每次迭代中使用完整数据集,而是能够使用小批量数据,每次迭代时轻微移动质心。这加快了算法的速度(通常提高了三到四倍),并使其能够对不适合内存的大型数据集进行聚类。Scikit-Learn 在MiniBatchKMeans类中实现了这个算法,您可以像使用KMeans类一样使用它:
from sklearn.cluster import MiniBatchKMeans
minibatch_kmeans = MiniBatchKMeans(n_clusters=5, random_state=42)
minibatch_kmeans.fit(X)
如果数据集不适合内存,最简单的选择是使用memmap类,就像我们在第八章中对增量 PCA 所做的那样。或者,您可以一次传递一个小批量到partial_fit()方法,但这将需要更多的工作,因为您需要执行多次初始化并自行选择最佳的初始化。
尽管小批量k-means 算法比常规k-means 算法快得多,但其惯性通常略差一些。您可以在图 9-6 中看到这一点:左侧的图比较了在先前的五个斑点数据集上使用不同数量的簇k训练的小批量k-means 和常规k-means 模型的惯性。两条曲线之间的差异很小,但是可见的。在右侧的图中,您可以看到小批量k-means 在这个数据集上大约比常规k-means 快 3.5 倍。
![mls3 0906]()
图 9-6。小批量k-means 的惯性高于k-means(左侧),但速度更快(右侧),特别是当k增加时
寻找最佳簇的数量
到目前为止,我们将簇的数量k设置为 5,因为通过观察数据,很明显这是正确的簇数量。但通常情况下,要知道如何设置k并不那么容易,如果将其设置为错误的值,结果可能会非常糟糕。如您在图 9-7 中所见,对于这个数据集,将k设置为 3 或 8 会导致相当糟糕的模型。
您可能会认为您可以选择惯性最低的模型。不幸的是,事情并不那么简单。k=3 的惯性约为 653.2,远高于k=5(211.6)。但是对于k=8,惯性只有 119.1。当我们增加k时,惯性并不是一个好的性能指标,因为它会不断降低。事实上,簇越多,每个实例距离其最近的质心就越近,因此惯性就会越低。让我们将惯性作为k的函数绘制出来。当我们这样做时,曲线通常包含一个称为“拐点”的拐点(见图 9-8)。
![mls3 0907]()
图 9-7。簇数量的错误选择:当k太小时,独立的簇会合并在一起(左侧),当k太大时,一些簇会被切割成多个部分(右侧)
![mls3 0908]()
图 9-8。将惯性作为簇数量k的函数绘制出来
正如您所看到的,随着k增加到 4,惯性迅速下降,但随着继续增加k,下降速度变得更慢。这条曲线大致呈手臂形状,并且在k=4 处有一个拐点。因此,如果我们不知道更好的选择,我们可能会认为 4 是一个不错的选择:任何更低的值都会有戏剧性的效果,而任何更高的值都不会有太大帮助,我们可能只是无缘无故地将完全良好的簇一分为二。
选择最佳聚类数值的技术相当粗糙。一个更精确(但也更耗费计算资源)的方法是使用轮廓分数,它是所有实例的平均轮廓系数。实例的轮廓系数等于(b - a)/ max(a, b),其中a是同一簇中其他实例的平均距离(即平均簇内距离),b是最近簇的平均距离(即到下一个最近簇实例的平均距离,定义为最小化b的簇,不包括实例自己的簇)。轮廓系数的取值范围在-1 到+1 之间。接近+1 的系数意味着实例很好地位于自己的簇内,并远离其他簇,而接近 0 的系数意味着它靠近簇边界;最后,接近-1 的系数意味着实例可能被分配到错误的簇。
要计算轮廓分数,您可以使用 Scikit-Learn 的silhouette_score()函数,将数据集中的所有实例和它们被分配的标签传递给它:
>>> from sklearn.metrics import silhouette_score
>>> silhouette_score(X, kmeans.labels_)
0.655517642572828
让我们比较不同聚类数的轮廓分数(参见图 9-9)。
![mls3 0909]()
图 9-9. 使用轮廓分数选择聚类数k
正如您所看到的,这种可视化比之前的更丰富:虽然它确认k=4 是一个非常好的选择,但它也突出了k=5 也相当不错,比k=6 或 7 好得多。这在比较惯性时是看不到的。
当我们绘制每个实例的轮廓系数时,按照它们被分配的簇和系数值进行排序,可以获得更具信息量的可视化结果。这被称为轮廓图(参见图 9-10)。每个图包含一个刀形图形,表示一个簇。形状的高度表示簇中实例的数量,宽度代表簇中实例的轮廓系数排序(宽度越宽越好)。
垂直虚线代表每个聚类数的平均轮廓分数。当一个簇中的大多数实例的系数低于这个分数时(即如果许多实例停在虚线之前,停在左边),那么这个簇就相当糟糕,因为这意味着它的实例与其他簇太接近。在这里我们可以看到当k=3 或 6 时,我们得到了糟糕的簇。但是当k=4 或 5 时,簇看起来相当不错:大多数实例延伸到虚线右侧,接近 1.0。当k=4 时,索引为 1 的簇(从底部数第二个)相当大。当k=5 时,所有簇的大小相似。因此,尽管k=4 的整体轮廓分数略高于k=5,但使用k=5 似乎是个好主意,以获得大小相似的簇。
![mls3 0910]()
图 9-10. 分析不同k值的轮廓图
k 均值的限制
尽管 k 均值有许多优点,尤其是快速和可扩展,但并不完美。正如我们所看到的,需要多次运行算法以避免次优解,而且您需要指定聚类数,这可能会很麻烦。此外,当聚类具有不同大小、不同密度或非球形形状时,k 均值的表现并不好。例如,图 9-11 显示了 k 均值如何对包含三个椭圆形簇的数据集进行聚类,这些簇具有不同的维度、密度和方向。
正如您所看到的,这两种解决方案都不太好。左侧的解决方案更好,但仍然将中间簇的 25%切断并分配给右侧的簇。右侧的解决方案只是糟糕透顶,尽管其惯性更低。因此,根据数据的不同,不同的聚类算法可能表现更好。在这些类型的椭圆形簇上,高斯混合模型效果很好。
![mls3 0911]()
图 9-11。k-means 未能正确聚类这些椭圆形斑点
提示
在运行k-means 之前,对输入特征进行缩放很重要(请参阅第二章)。否则,聚类可能会被拉伸得很长,k-means 的性能会很差。缩放特征并不能保证所有聚类都是漂亮的球形,但通常有助于k-means。
现在让我们看看我们可以从聚类中受益的几种方式。我们将使用k-means,但请随意尝试其他聚类算法。
使用聚类进行图像分割
图像分割是将图像分割成多个段的任务。有几种变体:
-
在颜色分割中,具有相似颜色的像素被分配到同一段。在许多应用中,这已经足够了。例如,如果您想要分析卫星图像以测量某个区域的总森林面积,颜色分割可能就足够了。
-
在语义分割中,属于相同对象类型的所有像素都被分配到同一段。例如,在自动驾驶汽车的视觉系统中,所有属于行人图像的像素可能被分配到“行人”段(将包含所有行人的一个段)。
-
在实例分割中,属于同一独立对象的所有像素都分配给同一段。在这种情况下,每个行人将有一个不同的段。
今天在语义或实例分割方面的最新技术是基于卷积神经网络的复杂架构(请参阅第十四章)。在本章中,我们将专注于(简单得多的)颜色分割任务,使用k-means。
我们将首先导入 Pillow 包(Python Imaging Library 的继任者),然后使用它加载ladybug.png图像(请参见图 9-12 中左上角的图像),假设它位于filepath处:
>>> import PIL
>>> image = np.asarray(PIL.Image.open(filepath))
>>> image.shape
(533, 800, 3)
图像表示为 3D 数组。第一维的大小是高度;第二维是宽度;第三维是颜色通道的数量,在这种情况下是红色、绿色和蓝色(RGB)。换句话说,对于每个像素,有一个包含红色、绿色和蓝色强度的 3D 向量,取值范围为 0 到 255 的无符号 8 位整数。一些图像可能具有较少的通道(例如灰度图像,只有一个通道),而一些图像可能具有更多的通道(例如具有额外alpha 通道用于透明度的图像,或者卫星图像,通常包含用于额外光频率的通道(如红外线))。
以下代码将数组重塑为 RGB 颜色的长列表,然后使用k-means 将这些颜色聚类为八个簇。它创建一个segmented_img数组,其中包含每个像素的最近簇中心(即每个像素的簇的平均颜色),最后将此数组重塑为原始图像形状。第三行使用了高级 NumPy 索引;例如,如果kmeans_.labels_中的前 10 个标签等于 1,则segmented_img中的前 10 种颜色等于kmeans.cluster_centers_[1]:
X = image.reshape(-1, 3)
kmeans = KMeans(n_clusters=8, random_state=42).fit(X)
segmented_img = kmeans.cluster_centers_[kmeans.labels_]
segmented_img = segmented_img.reshape(image.shape)
这输出了图 9-12 右上方显示的图像。您可以尝试不同数量的簇,如图所示。当您使用少于八个簇时,请注意瓢虫鲜艳的红色未能获得自己的簇:它与环境中的颜色合并在一起。这是因为k-means 更喜欢相似大小的簇。瓢虫很小——比图像的其他部分小得多——所以即使它的颜色很鲜艳,k-means 也无法为其分配一个簇。
![mls3 0912]()
图 9-12. 使用k-means 进行图像分割,使用不同数量的颜色簇
这并不太难,对吧?现在让我们看看聚类的另一个应用。
使用聚类进行半监督学习
聚类的另一个用例是在半监督学习中,当我们有大量未标记的实例和很少带标签的实例时。在本节中,我们将使用数字数据集,这是一个简单的类似 MNIST 的数据集,包含 1,797 个灰度 8×8 图像,代表数字 0 到 9。首先,让我们加载并拆分数据集(已经洗牌):
from sklearn.datasets import load_digits
X_digits, y_digits = load_digits(return_X_y=True)
X_train, y_train = X_digits[:1400], y_digits[:1400]
X_test, y_test = X_digits[1400:], y_digits[1400:]
我们假装只有 50 个实例的标签。为了获得基准性能,让我们在这 50 个带标签的实例上训练一个逻辑回归模型:
from sklearn.linear_model import LogisticRegression
n_labeled = 50
log_reg = LogisticRegression(max_iter=10_000)
log_reg.fit(X_train[:n_labeled], y_train[:n_labeled])
然后我们可以在测试集上测量这个模型的准确率(请注意测试集必须带标签):
>>> log_reg.score(X_test, y_test)
0.7481108312342569
模型的准确率只有 74.8%。这并不好:实际上,如果您尝试在完整的训练集上训练模型,您会发现它将达到约 90.7%的准确率。让我们看看如何做得更好。首先,让我们将训练集聚类成 50 个簇。然后,对于每个簇,我们将找到距离质心最近的图像。我们将称这些图像为代表性图像:
k = 50
kmeans = KMeans(n_clusters=k, random_state=42)
X_digits_dist = kmeans.fit_transform(X_train)
representative_digit_idx = np.argmin(X_digits_dist, axis=0)
X_representative_digits = X_train[representative_digit_idx]
图 9-13 显示了 50 个代表性图像。
![mls3 0913]()
图 9-13. 五十个代表性数字图像(每个簇一个)
让我们看看每个图像并手动标记它们:
y_representative_digits = np.array([1, 3, 6, 0, 7, 9, 2, ..., 5, 1, 9, 9, 3, 7])
现在我们有一个只有 50 个带标签实例的数据集,但它们不是随机实例,而是每个实例都是其簇的代表性图像。让我们看看性能是否有所提高:
>>> log_reg = LogisticRegression(max_iter=10_000)
>>> log_reg.fit(X_representative_digits, y_representative_digits)
>>> log_reg.score(X_test, y_test)
0.8488664987405542
哇!我们的准确率从 74.8%跳升到 84.9%,尽管我们仍然只在 50 个实例上训练模型。由于标记实例通常成本高昂且繁琐,特别是当必须由专家手动完成时,将代表性实例标记而不仅仅是随机实例是一个好主意。
但也许我们可以再进一步:如果我们将标签传播到同一簇中的所有其他实例怎么办?这被称为标签传播:
y_train_propagated = np.empty(len(X_train), dtype=np.int64)
for i in range(k):
y_train_propagated[kmeans.labels_ == i] = y_representative_digits[i]
现在让我们再次训练模型并查看其性能:
>>> log_reg = LogisticRegression()
>>> log_reg.fit(X_train, y_train_propagated)
>>> log_reg.score(X_test, y_test)
0.8942065491183879
我们又获得了显著的准确率提升!让我们看看如果忽略距离其簇中心最远的 1%实例是否能做得更好:这应该消除一些异常值。以下代码首先计算每个实例到其最近簇中心的距离,然后对于每个簇,将 1%最大的距离设置为-1。最后,它创建一个不包含这些标记为-1 距离的实例的集合:
percentile_closest = 99
X_cluster_dist = X_digits_dist[np.arange(len(X_train)), kmeans.labels_]
for i in range(k):
in_cluster = (kmeans.labels_ == i)
cluster_dist = X_cluster_dist[in_cluster]
cutoff_distance = np.percentile(cluster_dist, percentile_closest)
above_cutoff = (X_cluster_dist > cutoff_distance)
X_cluster_dist[in_cluster & above_cutoff] = -1
partially_propagated = (X_cluster_dist != -1)
X_train_partially_propagated = X_train[partially_propagated]
y_train_partially_propagated = y_train_propagated[partially_propagated]
现在让我们再次在这部分传播的数据集上训练模型,看看我们能获得什么准确率:
>>> log_reg = LogisticRegression(max_iter=10_000)
>>> log_reg.fit(X_train_partially_propagated, y_train_partially_propagated)
>>> log_reg.score(X_test, y_test)
0.9093198992443325
很棒!只有 50 个带标签的实例(平均每类只有 5 个示例!)我们获得了 90.9%的准确率,实际上略高于我们在完全带标签的数字数据集上获得的性能(90.7%)。这在一定程度上要归功于我们删除了一些异常值,另一部分原因是传播的标签实际上相当不错——它们的准确率约为 97.5%,如下面的代码所示:
>>> (y_train_partially_propagated == y_train[partially_propagated]).mean()
0.9755555555555555
提示
Scikit-Learn 还提供了两个可以自动传播标签的类:sklearn.semi_supervised包中的LabelSpreading和LabelPropagation。这两个类构建了所有实例之间的相似性矩阵,并迭代地将标签从有标签的实例传播到相似的无标签实例。还有一个非常不同的类叫做SelfTrainingClassifier,在相同的包中:你给它一个基础分类器(比如RandomForestClassifier),它会在有标签的实例上训练它,然后用它来预测无标签样本的标签。然后,它会使用它最有信心的标签更新训练集,并重复这个训练和标记的过程,直到不能再添加标签为止。这些技术并非万能之策,但它们偶尔可以为你的模型提供一点提升。
在我们继续讨论高斯混合模型之前,让我们看一下 DBSCAN,这是另一种流行的聚类算法,它基于局部密度估计展示了一种非常不同的方法。这种方法允许算法识别任意形状的簇。
DBSCAN
带有噪声的基于密度的空间聚类应用(DBSCAN)算法将连续的高密度区域定义为簇。它的工作原理如下:
-
对于每个实例,算法计算距离它小于一个小距离ε(epsilon)的实例有多少个。这个区域被称为实例的ε-邻域。
-
如果一个实例在其ε-邻域中至少有min_samples个实例(包括自身),那么它被视为核心实例。换句话说,核心实例是位于密集区域的实例。
-
所有在核心实例邻域中的实例都属于同一个簇。这个邻域可能包括其他核心实例;因此,一长串相邻的核心实例形成一个单一的簇。
-
任何不是核心实例且其邻域中没有核心实例的实例都被视为异常。
如果所有簇都被低密度区域很好地分隔开,这个算法就会很有效。Scikit-Learn 中的DBSCAN类使用起来就像你期望的那样简单。让我们在第五章中介绍的 moons 数据集上测试一下:
from sklearn.cluster import DBSCAN
from sklearn.datasets import make_moons
X, y = make_moons(n_samples=1000, noise=0.05)
dbscan = DBSCAN(eps=0.05, min_samples=5)
dbscan.fit(X)
现在所有实例的标签都可以在labels_实例变量中找到:
>>> dbscan.labels_
array([ 0, 2, -1, -1, 1, 0, 0, 0, 2, 5, [...], 3, 3, 4, 2, 6, 3])
请注意,一些实例的簇索引等于-1,这意味着它们被算法视为异常值。核心实例的索引可以在core_sample_indices_实例变量中找到,核心实例本身可以在components_实例变量中找到:
>>> dbscan.core_sample_indices_
array([ 0, 4, 5, 6, 7, 8, 10, 11, [...], 993, 995, 997, 998, 999])
>>> dbscan.components_
array([[-0.02137124, 0.40618608],
[-0.84192557, 0.53058695],
[...],
[ 0.79419406, 0.60777171]])
这种聚类在图 9-14 的左图中表示。正如你所看到的,它识别出了相当多的异常值,以及七个不同的簇。多么令人失望!幸运的是,如果我们通过增加eps到 0.2 来扩大每个实例的邻域,我们得到了右侧的聚类,看起来完美。让我们继续使用这个模型。
![mls3 0914]()
图 9-14。使用两个不同邻域半径的 DBSCAN 聚类
令人惊讶的是,DBSCAN类没有predict()方法,尽管它有一个fit_predict()方法。换句话说,它不能预测一个新实例属于哪个簇。这个决定是因为不同的分类算法对不同的任务可能更好,所以作者决定让用户选择使用哪个。此外,这并不难实现。例如,让我们训练一个KNeighborsClassifier:
from sklearn.neighbors import KNeighborsClassifier
knn = KNeighborsClassifier(n_neighbors=50)
knn.fit(dbscan.components_, dbscan.labels_[dbscan.core_sample_indices_])
现在,给定一些新实例,我们可以预测它们最有可能属于哪些簇,甚至为每个簇估计一个概率:
>>> X_new = np.array([[-0.5, 0], [0, 0.5], [1, -0.1], [2, 1]])
>>> knn.predict(X_new)
array([1, 0, 1, 0])
>>> knn.predict_proba(X_new)
array([[0.18, 0.82],
[1\. , 0\. ],
[0.12, 0.88],
[1\. , 0\. ]])
请注意,我们只在核心实例上训练了分类器,但我们也可以选择在所有实例上或除异常值外的所有实例上进行训练:这个选择取决于最终的任务。
决策边界在图 9-15 中表示(十字表示X_new中的四个实例)。请注意,由于训练集中没有异常值,分类器总是选择一个集群,即使该集群很远。很容易引入一个最大距离,这样远离两个集群的两个实例将被分类为异常值。要做到这一点,使用KNeighborsClassifier的kneighbors()方法。给定一组实例,它返回训练集中k个最近邻居的距离和索引(两个矩阵,每个有k列):
>>> y_dist, y_pred_idx = knn.kneighbors(X_new, n_neighbors=1)
>>> y_pred = dbscan.labels_[dbscan.core_sample_indices_][y_pred_idx]
>>> y_pred[y_dist > 0.2] = -1
>>> y_pred.ravel()
array([-1, 0, 1, -1])
![mls3 0915]()
图 9-15. 两个集群之间的决策边界
简而言之,DBSCAN 是一个非常简单但功能强大的算法,能够识别任意形状的任意数量的集群。它对异常值具有鲁棒性,只有两个超参数(eps和min_samples)。然而,如果集群之间的密度差异显著,或者某些集群周围没有足够低密度的区域,DBSCAN 可能无法正确捕捉所有集群。此外,它的计算复杂度大约为O(m²n),因此不适用于大型数据集。
提示
您可能还想尝试层次 DBSCAN(HDBSCAN),它在scikit-learn-contrib项目中实现,通常比 DBSCAN 更好地找到不同密度的集群。
其他聚类算法
Scikit-Learn 实现了几种更多的聚类算法,你应该看一看。我无法在这里详细介绍它们所有,但这里是一个简要概述:
凝聚聚类
从底向上构建一个集群的层次结构。想象许多微小的气泡漂浮在水面上,逐渐相互连接,直到形成一个大的气泡群。类似地,在每次迭代中,凝聚聚类将最近的一对集群连接起来(从单个实例开始)。如果你画出一棵树,每对合并的集群都有一个分支,你将得到一个二叉树的集群,其中叶子是单个实例。这种方法可以捕捉各种形状的集群;它还会生成一个灵活且信息丰富的集群树,而不是强迫你选择特定的集群规模,并且可以与任何成对距离一起使用。如果提供一个连接矩阵,它可以很好地扩展到大量实例,连接矩阵是一个稀疏的 m × m 矩阵,指示哪些实例对是邻居(例如,由 sklearn.neighbors.kneighbors_graph() 返回)。没有连接矩阵,该算法在大型数据集上的扩展性不佳。
BIRCH
平衡迭代减少和层次聚类(BIRCH)算法专门设计用于非常大的数据集,只要特征数量不太大(<20),它可能比批量k-means 更快,结果类似。在训练过程中,它构建一个树结构,包含足够的信息,可以快速将每个新实例分配到一个集群,而无需将所有实例存储在树中:这种方法允许它在处理大型数据集时使用有限的内存。
均值漂移
该算法首先在每个实例上放置一个圆圈;然后对于每个圆圈,计算其中所有实例的平均值,并将圆圈移动,使其位于平均值中心。接下来,迭代这个平移步骤,直到所有圆圈停止移动(即,直到它们每个都位于包含的实例的平均值上)。均值漂移将圆圈移动到更高密度的方向,直到每个圆圈找到局部密度最大值。最后,所有圆圈已经定居在同一位置(或足够接近)的实例被分配到同一个簇。均值漂移具有与 DBSCAN 相同的一些特性,例如它可以找到任意形状的任意数量的簇,它只有很少的超参数(只有一个——圆圈的半径,称为带宽),并且依赖于局部密度估计。但与 DBSCAN 不同的是,均值漂移在簇内密度变化时倾向于将簇切割成片。不幸的是,它的计算复杂度为O(m²n),因此不适用于大型数据集。
亲和传播
在这个算法中,实例之间反复交换消息,直到每个实例都选出另一个实例(或自己)来代表它。这些被选出的实例被称为典范。每个典范和所有选举它的实例形成一个簇。在现实生活中的政治中,你通常希望投票给一个观点与你相似的候选人,但你也希望他们赢得选举,所以你可能会选择一个你不完全同意的候选人,但他更受欢迎。你通常通过民意调查来评估受欢迎程度。亲和传播以类似的方式工作,它倾向于选择位于簇中心附近的典范,类似于k-means。但与k-means 不同的是,你不必提前选择簇的数量:它是在训练过程中确定的。此外,亲和传播可以很好地处理不同大小的簇。不幸的是,这个算法的计算复杂度为O(m²),因此不适用于大型数据集。
谱聚类
该算法使用实例之间的相似性矩阵,并从中创建一个低维嵌入(即,降低矩阵的维度),然后在这个低维空间中使用另一个聚类算法(Scikit-Learn 的实现使用k-means)。谱聚类可以捕捉复杂的簇结构,也可以用于切割图形(例如,识别社交网络上的朋友簇)。当实例数量较大时,它不会很好地扩展,并且当簇的大小差异很大时,它的表现也不好。
现在让我们深入研究高斯混合模型,它可以用于密度估计、聚类和异常检测。
高斯混合模型
高斯混合模型(GMM)是一个概率模型,假设实例是从几个高斯分布的混合中生成的,这些分布的参数是未知的。从单个高斯分布生成的所有实例形成一个簇,通常看起来像一个椭圆体。每个簇可以具有不同的椭圆形状、大小、密度和方向,就像图 9-11 中所示。当你观察一个实例时,你知道它是从高斯分布中生成的一个,但你不知道是哪一个,也不知道这些分布的参数是什么。
有几种 GMM 变体。在GaussianMixture类中实现的最简单的变体中,你必须事先知道高斯分布的数量k。假定数据集X是通过以下概率过程生成的:
-
对于每个实例,从k个簇中随机选择一个。选择第j个簇的概率是簇的权重ϕ^((j)).⁶ 选择第i个实例的簇的索引被记为z^((i)).
-
如果第i个实例被分配到第j个聚类(即z^((i)) = j),则该实例的位置x((*i*))是从具有均值**μ**((j))和协方差矩阵Σ((*j*))的高斯分布中随机抽样的。这表示**x**((i)) ~ 𝒩(μ^((j)), Σ^((j))).
那么,您可以用这样的模型做什么呢?嗯,给定数据集X,您通常会从估计权重ϕ和所有分布参数μ((1))到**μ**((k))和Σ((1))到**Σ**((k))开始。Scikit-Learn 的GaussianMixture类使这变得非常容易:
from sklearn.mixture import GaussianMixture
gm = GaussianMixture(n_components=3, n_init=10)
gm.fit(X)
让我们看看算法估计的参数:
>>> gm.weights_
array([0.39025715, 0.40007391, 0.20966893])
>>> gm.means_
array([[ 0.05131611, 0.07521837],
[-1.40763156, 1.42708225],
[ 3.39893794, 1.05928897]])
>>> gm.covariances_
array([[[ 0.68799922, 0.79606357],
[ 0.79606357, 1.21236106]],
[[ 0.63479409, 0.72970799],
[ 0.72970799, 1.1610351 ]],
[[ 1.14833585, -0.03256179],
[-0.03256179, 0.95490931]]])
太好了,运行得很顺利!事实上,三个聚类中有两个分别生成了 500 个实例,而第三个聚类只包含 250 个实例。因此,真实的聚类权重分别为 0.4、0.4 和 0.2,这大致是算法找到的。同样,真实的均值和协方差矩阵与算法找到的相当接近。但是如何实现的呢?这个类依赖于期望最大化(EM)算法,它与k-means 算法有许多相似之处:它也随机初始化聚类参数,然后重复两个步骤直到收敛,首先将实例分配给聚类(这称为期望步骤),然后更新聚类(这称为最大化步骤)。听起来很熟悉,对吧?在聚类的背景下,您可以将 EM 视为k-means 的一种泛化,它不仅找到聚类中心(μ((1))到**μ**((k))),还找到它们的大小、形状和方向(Σ((1))到**Σ**((k))),以及它们的相对权重(ϕ((1))到*ϕ*((k)))。不过,与k-means 不同,EM 使用软聚类分配,而不是硬分配。对于每个实例,在期望步骤中,算法根据当前的聚类参数估计它属于每个聚类的概率。然后,在最大化步骤中,每个聚类使用数据集中的所有实例进行更新,每个实例的权重由估计的属于该聚类的概率加权。这些概率称为聚类对实例的责任。在最大化步骤中,每个聚类的更新将主要受到它最负责的实例的影响。
警告
不幸的是,就像k-means 一样,EM 可能会收敛到较差的解决方案,因此需要多次运行,仅保留最佳解决方案。这就是为什么我们将n_init设置为 10。请注意:默认情况下,n_init设置为 1。
您可以检查算法是否收敛以及需要多少次迭代:
>>> gm.converged_
True
>>> gm.n_iter_
4
现在您已经估计了每个聚类的位置、大小、形状、方向和相对权重,模型可以轻松地将每个实例分配到最可能的聚类(硬聚类),或者估计它属于特定聚类的概率(软聚类)。只需使用predict()方法进行硬聚类,或者使用predict_proba()方法进行软聚类:
>>> gm.predict(X)
array([0, 0, 1, ..., 2, 2, 2])
>>> gm.predict_proba(X).round(3)
array([[0.977, 0\. , 0.023],
[0.983, 0.001, 0.016],
[0\. , 1\. , 0\. ],
...,
[0\. , 0\. , 1\. ],
[0\. , 0\. , 1\. ],
[0\. , 0\. , 1\. ]])
高斯混合模型是一种生成模型,这意味着您可以从中抽样新实例(请注意,它们按簇索引排序):
>>> X_new, y_new = gm.sample(6)
>>> X_new
array([[-0.86944074, -0.32767626],
[ 0.29836051, 0.28297011],
[-2.8014927 , -0.09047309],
[ 3.98203732, 1.49951491],
[ 3.81677148, 0.53095244],
[ 2.84104923, -0.73858639]])
>>> y_new
array([0, 0, 1, 2, 2, 2])
还可以估计模型在任何给定位置的密度。这是通过使用score_samples()方法实现的:对于给定的每个实例,该方法估计该位置处的概率密度函数(PDF)的对数。得分越高,密度越大:
>>> gm.score_samples(X).round(2)
array([-2.61, -3.57, -3.33, ..., -3.51, -4.4 , -3.81])
如果计算这些分数的指数,您将得到给定实例位置处 PDF 的值。这些不是概率,而是概率密度:它们可以取任何正值,而不仅仅是 0 到 1 之间的值。要估计实例将落入特定区域的概率,您需要在该区域上积分(如果在可能实例位置的整个空间上这样做,结果将为 1)。
图 9-16 显示了聚类均值、决策边界(虚线)和该模型的密度轮廓。
![mls3 0916]()
图 9-16. 训练的高斯混合模型的聚类均值、决策边界和密度轮廓
太棒了!算法显然找到了一个很好的解决方案。当然,我们通过使用一组二维高斯分布生成数据使其任务变得容易(不幸的是,现实生活中的数据并不总是如此高斯和低维)。我们还给出了正确的聚类数。当维度很多、聚类很多或实例很少时,EM 可能会难以收敛到最佳解决方案。您可能需要通过限制算法需要学习的参数数量来降低任务的难度。一种方法是限制聚类可以具有的形状和方向的范围。这可以通过对协方差矩阵施加约束来实现。为此,请将covariance_type超参数设置为以下值之一:
"spherical"
所有聚类必须是球形的,但它们可以具有不同的直径(即,不同的方差)。
"diag"
聚类可以采用任何椭球形状的任何大小,但椭球体的轴必须与坐标轴平行(即,协方差矩阵必须是对角线的)。
"tied"
所有聚类必须具有相同的椭球形状、大小和方向(即,所有聚类共享相同的协方差矩阵)。
默认情况下,covariance_type等于"full",这意味着每个聚类可以采用任何形状、大小和方向(它有自己的无约束协方差矩阵)。图 9-17 显示了当covariance_type设置为"tied"或"spherical"时 EM 算法找到的解决方案。
![mls3 0917]()
图 9-17. 绑定聚类(左)和球形聚类(右)的高斯混合模型
注意
训练GaussianMixture模型的计算复杂度取决于实例数m、维度数n、聚类数k以及协方差矩阵的约束。如果covariance_type为"spherical"或"diag",则为O(kmn),假设数据具有聚类结构。如果covariance_type为"tied"或"full",则为O(kmn² + kn³),因此不适用于大量特征。
高斯混合模型也可以用于异常检测。我们将在下一节中看到如何使用。
使用高斯混合模型进行异常检测
使用高斯混合模型进行异常检测非常简单:位于低密度区域的任何实例都可以被视为异常。您必须定义要使用的密度阈值。例如,在一个试图检测有缺陷产品的制造公司中,有缺陷产品的比例通常是众所周知的。假设等于 2%。然后,您将密度阈值设置为导致有 2%的实例位于低于该阈值密度区域的值。如果您注意到您得到了太多的假阳性(即被标记为有缺陷的完全良好产品),您可以降低阈值。相反,如果您有太多的假阴性(即系统未标记为有缺陷的有缺陷产品),您可以增加阈值。这是通常的精确度/召回率权衡(参见第三章)。以下是使用第四百分位数最低密度作为阈值(即,大约 4%的实例将被标记为异常)来识别异常值的方法:
densities = gm.score_samples(X)
density_threshold = np.percentile(densities, 2)
anomalies = X[densities < density_threshold]
图 9-18 将这些异常值表示为星号。
一个密切相关的任务是新颖性检测:它与异常检测不同之处在于,算法被假定在一个未被异常值污染的“干净”数据集上进行训练,而异常检测则不做出这种假设。事实上,异常值检测通常用于清理数据集。
提示
高斯混合模型尝试拟合所有数据,包括异常值;如果异常值过多,这将使模型对“正常性”的看法产生偏见,一些异常值可能会被错误地视为正常值。如果发生这种情况,您可以尝试拟合模型一次,使用它来检测并删除最极端的异常值,然后再次在清理后的数据集上拟合模型。另一种方法是使用鲁棒协方差估计方法(参见EllipticEnvelope类)。
![mls3 0918]()
图 9-18. 使用高斯混合模型进行异常检测
就像k-means 一样,GaussianMixture算法要求您指定聚类数。那么如何找到那个数字呢?
选择聚类数
使用k-means,您可以使用惯性或轮廓分数来选择适当的聚类数。但是对于高斯混合,当聚类不是球形或大小不同时,使用这些度量是不可靠的。相反,您可以尝试找到最小化理论信息准则的模型,例如贝叶斯信息准则(BIC)或阿凯克信息准则(AIC),在方程 9-1 中定义。
方程 9-1. 贝叶斯信息准则(BIC)和阿凯克信息准则(AIC)
在这些方程中:
-
m 一如既往是实例的数量。
-
p 是模型学习的参数数量。
-
是模型的似然函数的最大化值。
BIC 和 AIC 都惩罚具有更多要学习的参数(例如更多聚类)的模型,并奖励拟合数据良好的模型。它们通常选择相同的模型。当它们不同时,BIC 选择的模型往往比 AIC 选择的模型更简单(参数更少),但往往不那么适合数据(特别是对于更大的数据集)。
要计算 BIC 和 AIC,请调用bic()和aic()方法:
>>> gm.bic(X)
8189.747000497186
>>> gm.aic(X)
8102.521720382148
图 9-20 显示了不同聚类数k的 BIC。如您所见,当k=3 时,BIC 和 AIC 都最低,因此这很可能是最佳选择。
![mls3 0920]()
图 9-20. 不同聚类数k的 AIC 和 BIC
贝叶斯高斯混合模型
与手动搜索最佳聚类数不同,您可以使用BayesianGaussianMixture类,该类能够将权重等于(或接近)零的不必要聚类。将聚类数n_components设置为一个您有充分理由认为大于最佳聚类数的值(这假设对问题有一些最小的了解),算法将自动消除不必要的聚类。例如,让我们将聚类数设置为 10,看看会发生什么:
>>> from sklearn.mixture import BayesianGaussianMixture
>>> bgm = BayesianGaussianMixture(n_components=10, n_init=10, random_state=42)
>>> bgm.fit(X)
>>> bgm.weights_.round(2)
array([0.4 , 0.21, 0.4 , 0\. , 0\. , 0\. , 0\. , 0\. , 0\. , 0\. ])
完美:该算法自动检测到只需要三个簇,并且得到的簇几乎与图 9-16 中的簇相同。
关于高斯混合模型的最后一点说明:尽管它们在具有椭圆形状的簇上表现很好,但在形状非常不同的簇上表现不佳。例如,让我们看看如果我们使用贝叶斯高斯混合模型来对月亮数据集进行聚类会发生什么(参见图 9-21)。
糟糕!该算法拼命搜索椭圆体,因此找到了八个不同的簇,而不是两个。密度估计并不太糟糕,因此这个模型可能可以用于异常检测,但它未能识别出这两个月亮。为了结束本章,让我们快速看一下几种能够处理任意形状簇的算法。
![mls3 0921]()
图 9-21. 将高斯混合拟合到非椭圆形簇
其他用于异常和新颖性检测的算法
Scikit-Learn 实现了其他专门用于异常检测或新颖性检测的算法:
快速 MCD(最小协方差行列式)
由EllipticEnvelope类实现,该算法对于异常值检测很有用,特别是用于清理数据集。它假设正常实例(内点)是从单个高斯分布(而不是混合)生成的。它还假设数据集中混入了未从该高斯分布生成的异常值。当算法估计高斯分布的参数(即围绕内点的椭圆包络的形状)时,它会小心地忽略那些最有可能是异常值的实例。这种技术提供了对椭圆包络更好的估计,从而使算法更好地识别异常值。
孤立森林
这是一种用于异常值检测的高效算法,特别适用于高维数据集。该算法构建了一个随机森林,其中每棵决策树都是随机生长的:在每个节点,它随机选择一个特征,然后选择一个随机阈值(在最小值和最大值之间)来将数据集分成两部分。数据集逐渐以这种方式被切割成片段,直到所有实例最终与其他实例隔离开来。异常通常远离其他实例,因此平均而言(在所有决策树中),它们往往比正常实例更快地被隔离。
局部离群因子(LOF)
这个算法也适用于异常值检测。它比较了给定实例周围的实例密度与其邻居周围的密度。异常通常比其k个最近邻更孤立。
一类 SVM
这个算法更适合用于新颖性检测。回想一下,一个核化的 SVM 分类器通过首先(隐式地)将所有实例映射到高维空间,然后在这个高维空间中使用线性 SVM 分类器来分离两个类别(参见第五章)。由于我们只有一个类的实例,一类 SVM 算法尝试在高维空间中将实例与原点分离。在原始空间中,这将对应于找到一个包含所有实例的小区域。如果一个新实例不在这个区域内,那么它就是异常值。有一些需要调整的超参数:用于核化 SVM 的通常超参数,以及一个边际超参数,对应于新实例被错误地认为是新颖的概率,而实际上是正常的。它的效果很好,特别是对于高维数据集,但像所有的 SVM 一样,它不适用于大型数据集。
PCA 和其他具有inverse_transform()方法的降维技术
如果将正常实例的重建误差与异常的重建误差进行比较,后者通常会大得多。这是一种简单而通常相当有效的异常检测方法(请参阅本章的练习以获取示例)。
练习
-
你如何定义聚类?你能说出几种聚类算法吗?
-
聚类算法的主要应用有哪些?
-
描述两种在使用k-means 时选择正确聚类数量的技术。
-
什么是标签传播?为什么要实现它,以及如何实现?
-
你能说出两种可以扩展到大型数据集的聚类算法吗?还有两种寻找高密度区域的算法吗?
-
你能想到一个使用主动学习会有用的用例吗?你会如何实现它?
-
异常检测和新颖性检测之间有什么区别?
-
什么是高斯混合模型?可以用它来做什么任务?
-
你能说出使用高斯混合模型时找到正确聚类数量的两种技术吗?
-
经典的 Olivetti 人脸数据集包含 400 张灰度 64×64 像素的人脸图像。每个图像被展平为大小为 4,096 的 1D 向量。共有 40 个不同的人被拍摄(每人 10 次),通常的任务是训练一个模型,可以预测每张图片中代表的是哪个人。使用sklearn.datasets.fetch_olivetti_faces()函数加载数据集,然后将其分为训练集、验证集和测试集(注意数据集已经在 0 到 1 之间缩放)。由于数据集相当小,您可能希望使用分层抽样来确保每个集合中每个人的图像数量相同。接下来,使用k-means 对图像进行聚类,并确保有一个良好数量的聚类(使用本章讨论的技术之一)。可视化聚类:您是否在每个聚类中看到相似的面孔?
-
继续使用 Olivetti 人脸数据集,训练一个分类器来预测每张图片中代表的是哪个人,并在验证集上评估它。接下来,使用k-means 作为降维工具,并在减少的集合上训练一个分类器。寻找能让分类器获得最佳性能的聚类数量:你能达到什么性能?如果将减少集合的特征附加到原始特征上(再次搜索最佳聚类数量),会怎样?
-
在 Olivetti 人脸数据集上训练一个高斯混合模型。为了加快算法速度,您可能需要降低数据集的维度(例如,使用 PCA,保留 99%的方差)。使用模型生成一些新的面孔(使用sample()方法),并可视化它们(如果使用了 PCA,您需要使用其inverse_transform()方法)。尝试修改一些图像(例如旋转、翻转、变暗)并查看模型是否能检测到异常(即,比较正常图像和异常的score_samples()方法的输出)。
-
一些降维技术也可以用于异常检测。例如,取 Olivetti 人脸数据集并使用 PCA 进行降维,保留 99%的方差。然后计算每个图像的重建误差。接下来,取出前面练习中构建的一些修改后的图像,并查看它们的重建误差:注意它有多大。如果绘制一个重建图像,你会看到原因:它试图重建一个正常的脸。
这些练习的解决方案可在本章笔记本的末尾找到,网址为https://homl.info/colab3。
¹ 斯图尔特·P·劳埃德,“PCM 中的最小二乘量化”,IEEE 信息理论交易 28, no. 2(1982):129–137。
² 大卫·阿瑟和谢尔盖·瓦西利维茨基,“k-Means++: 小心播种的优势”,第 18 届 ACM-SIAM 离散算法研讨会论文集(2007 年):1027–1035。
³ 查尔斯·埃尔坎,“使用三角不等式加速 k 均值”,第 20 届国际机器学习会议论文集(2003 年):147–153。
⁴ 三角不等式是 AC ≤ AB + BC,其中 A、B 和 C 是三个点,AB、AC 和 BC 是这些点之间的距离。
⁵ 大卫·斯卡利,“Web 规模的 K 均值聚类”,第 19 届国际万维网会议论文集(2010 年):1177–1178。
⁶ Phi(ϕ或φ)是希腊字母表的第 21 个字母。
第二部分:神经网络和深度学习
第十章:使用 Keras 入门人工神经网络
鸟类启发我们飞行,牛蒡植物启发了钩带,自然启发了无数更多的发明。因此,看看大脑的结构以获取如何构建智能机器的灵感似乎是合乎逻辑的。这就是激发人工神经网络(ANNs)的逻辑,这是受到我们大脑中生物神经元网络启发的机器学习模型。然而,尽管飞机受到鸟类的启发,但它们不必拍打翅膀才能飞行。同样,人工神经网络逐渐与其生物表亲有所不同。一些研究人员甚至主张我们应该完全放弃生物类比(例如,使用“单元”而不是“神经元”),以免将我们的创造力限制在生物学上可行的系统中。^(1)
ANNs 是深度学习的核心。它们多才多艺,强大且可扩展,使其成为处理大规模和高度复杂的机器学习任务的理想选择,例如对数十亿张图像进行分类(例如 Google Images),为语音识别服务提供动力(例如苹果的 Siri),每天向数亿用户推荐最佳观看视频(例如 YouTube),或学会击败围棋世界冠军(DeepMind 的 AlphaGo)。
本章的第一部分介绍了人工神经网络,从快速浏览最初的 ANN 架构开始,一直到如今广泛使用的多层感知器(其他架构将在接下来的章节中探讨)。在第二部分中,我们将看看如何使用 TensorFlow 的 Keras API 实现神经网络。这是一个设计精美且简单的高级 API,用于构建、训练、评估和运行神经网络。但不要被它的简单性所迷惑:它足够表达和灵活,可以让您构建各种各样的神经网络架构。实际上,对于大多数用例来说,它可能已经足够了。如果您需要额外的灵活性,您始终可以使用其较低级别的 API 编写自定义 Keras 组件,甚至直接使用 TensorFlow,正如您将在第十二章中看到的。
但首先,让我们回到过去,看看人工神经网络是如何产生的!
从生物到人工神经元
令人惊讶的是,人工神经网络已经存在了相当长的时间:它们最早是由神经生理学家沃伦·麦卡洛克和数学家沃尔特·皮茨于 1943 年首次提出的。在他们的里程碑论文^(2)“神经活动中内在的思想逻辑演算”,麦卡洛克和皮茨提出了一个简化的计算模型,说明了生物神经元如何在动物大脑中共同工作以使用命题逻辑执行复杂计算。这是第一个人工神经网络架构。从那时起,许多其他架构已经被发明,正如您将看到的。
人工神经网络的早期成功导致了人们普遍相信我们很快将与真正智能的机器交谈。当在 1960 年代清楚地意识到这一承诺将无法实现(至少在相当长一段时间内)时,资金转向其他地方,人工神经网络进入了一个漫长的冬天。在 20 世纪 80 年代初,发明了新的架构并开发了更好的训练技术,引发了对连接主义的兴趣复苏,即神经网络的研究。但进展缓慢,到了 20 世纪 90 年代,其他强大的机器学习技术已经被发明出来,例如支持向量机(参见第五章)。这些技术似乎提供了比人工神经网络更好的结果和更强的理论基础,因此神经网络的研究再次被搁置。
我们现在正在目睹对人工神经网络的又一波兴趣。这波潮流会像以前的那些一样消失吗?好吧,以下是一些有理由相信这一次不同的好理由,以及对人工神经网络的重新兴趣将对我们的生活产生更深远影响的原因:
-
现在有大量的数据可用于训练神经网络,人工神经网络在非常大型和复杂的问题上经常胜过其他机器学习技术。
-
自 1990 年以来计算能力的巨大增长现在使得在合理的时间内训练大型神经网络成为可能。这在一定程度上归功于摩尔定律(集成电路中的元件数量在过去 50 年里大约每 2 年翻一番),但也要感谢游戏行业,它刺激了数以百万计的强大 GPU 卡的生产。此外,云平台使这种能力对每个人都可获得。
-
训练算法已经得到改进。公平地说,它们与 1990 年代使用的算法只有略微不同,但这些相对较小的调整产生了巨大的积极影响。
-
一些人工神经网络的理论限制在实践中被证明是良性的。例如,许多人认为人工神经网络训练算法注定会陷入局部最优解,但事实证明,在实践中这并不是一个大问题,特别是对于更大的神经网络:局部最优解通常表现几乎和全局最优解一样好。
-
人工神经网络似乎已经进入了资金和进展的良性循环。基于人工神经网络的惊人产品经常成为头条新闻,这吸引了越来越多的关注和资金,导致了越来越多的进展和更多惊人的产品。
生物神经元
在我们讨论人工神经元之前,让我们快速看一下生物神经元(在图 10-1 中表示)。它是一种在动物大脑中大多数发现的不寻常的细胞。它由一个包含细胞核和大多数细胞复杂组分的细胞体组成,许多分支延伸称为树突,以及一个非常长的延伸称为轴突。轴突的长度可能仅比细胞体长几倍,或者长达成千上万倍。在其末端附近,轴突分裂成许多称为末梢的分支,而在这些分支的顶端是微小的结构称为突触终端(或简称突触),它们连接到其他神经元的树突或细胞体。生物神经元产生称为动作电位(APs,或简称信号)的短电脉冲,这些电脉冲沿着轴突传播,并使突触释放称为神经递质的化学信号。当一个神经元在几毫秒内接收到足够量的这些神经递质时,它会发出自己的电脉冲(实际上,这取决于神经递质,因为其中一些会抑制神经元的发放)。
![mls3 1001]()
图 10-1. 一个生物神经元⁴
因此,单个生物神经元似乎表现出简单的方式,但它们组织在一个庞大的网络中,有数十亿个神经元,每个神经元通常连接到成千上万个其他神经元。高度复杂的计算可以通过一个相当简单的神经元网络执行,就像一个复杂的蚁丘可以从简单的蚂蚁的共同努力中出现一样。生物神经网络(BNNs)的架构是活跃研究的主题,但大脑的某些部分已经被绘制出来。这些努力表明,神经元通常组织成连续的层,特别是在大脑的外层皮层(大脑的外层),如图 10-2 所示。
![mls3 1002]()
图 10-2. 生物神经网络中的多个层(人类皮层)⁶
使用神经元进行逻辑计算
McCulloch 和 Pitts 提出了生物神经元的一个非常简单的模型,后来被称为人工神经元:它具有一个或多个二进制(开/关)输入和一个二进制输出。当其输入中的活动超过一定数量时,人工神经元会激活其输出。在他们的论文中,McCulloch 和 Pitts 表明,即使使用这样简化的模型,也可以构建一个可以计算任何您想要的逻辑命题的人工神经元网络。为了了解这样一个网络是如何工作的,让我们构建一些执行各种逻辑计算的人工神经网络(请参见图 10-3),假设当至少两个输入连接处于活动状态时,神经元被激活。
![mls3 1003]()
图 10-3。执行简单逻辑计算的人工神经网络
让我们看看这些网络的作用:
-
左侧的第一个网络是恒等函数:如果神经元 A 被激活,则神经元 C 也会被激活(因为它从神经元 A 接收到两个输入信号);但如果神经元 A 处于关闭状态,则神经元 C 也会关闭。
-
第二个网络执行逻辑 AND 操作:只有当神经元 A 和 B 都被激活时,神经元 C 才会被激活(单个输入信号不足以激活神经元 C)。
-
第三个网络执行逻辑 OR 操作:只有当神经元 A 或神经元 B 被激活(或两者都被激活)时,神经元 C 才会被激活。
-
最后,如果我们假设一个输入连接可以抑制神经元的活动(这是生物神经元的情况),那么第四个网络将计算一个稍微更复杂的逻辑命题:只有当神经元 A 处于活动状态且神经元 B 处于关闭状态时,神经元 C 才会被激活。如果神经元 A 一直处于活动状态,那么您将得到一个逻辑 NOT:当神经元 B 处于关闭状态时,神经元 C 处于活动状态,反之亦然。
您可以想象这些网络如何组合以计算复杂的逻辑表达式(请参见本章末尾的练习示例)。
感知器
感知器是最简单的人工神经网络架构之一,由 Frank Rosenblatt 于 1957 年发明。它基于一个略有不同的人工神经元(见图 10-4)称为阈值逻辑单元(TLU),有时也称为线性阈值单元(LTU)。输入和输出是数字(而不是二进制的开/关值),每个输入连接都与一个权重相关联。TLU 首先计算其输入的线性函数:z = w[1] x[1] + w[2] x[2] + ⋯ + w[n] x[n] + b = w^⊺ x + b。然后它将结果应用于阶跃函数:hw = step(z)。因此,这几乎就像逻辑回归,只是它使用了一个阶跃函数而不是逻辑函数(第四章)。就像在逻辑回归中一样,模型参数是输入权重w和偏置项b。
![mls3 1004]()
图 10-4。TLU:计算其输入w^⊺ x的加权和,加上偏置项b,然后应用一个阶跃函数
感知器中最常用的阶跃函数是海维赛德阶跃函数(见方程式 10-1)。有时也会使用符号函数。
方程式 10-1。感知器中常用的阶跃函数(假设阈值=0)
一个单个的 TLU 可以用于简单的线性二元分类。它计算其输入的线性函数,如果结果超过阈值,则输出正类。否则,输出负类。这可能让你想起了逻辑回归(第四章)或线性 SVM 分类(第五章)。例如,你可以使用一个单个的 TLU 基于花瓣长度和宽度对鸢尾花进行分类。训练这样一个 TLU 需要找到正确的w[1]、w[2]和b的值(训练算法将很快讨论)。
一个感知器由一个或多个 TLU 组成,组织在一个单层中,其中每个 TLU 连接到每个输入。这样的一层被称为全连接层或密集层。输入构成输入层。由于 TLU 层产生最终输出,因此被称为输出层。例如,一个具有两个输入和三个输出的感知器在图 10-5 中表示。
![mls3 1005]()
图 10-5。具有两个输入和三个输出神经元的感知器的架构
这个感知器可以同时将实例分类为三个不同的二进制类别,这使它成为一个多标签分类器。它也可以用于多类分类。
由于线性代数的魔力,方程 10-2 可以用来高效地计算一层人工神经元对多个实例的输出。
方程 10-2。计算全连接层的输出
在这个方程中:
-
如常,X代表输入特征的矩阵。每个实例一行,每个特征一列。
-
权重矩阵W包含所有的连接权重。它每行对应一个输入,每列对应一个神经元。
-
偏置向量b包含所有的偏置项:每个神经元一个。
-
函数ϕ被称为激活函数:当人工神经元是 TLU 时,它是一个阶跃函数(我们将很快讨论其他激活函数)。
注意
在数学中,矩阵和向量的和是未定义的。然而,在数据科学中,我们允许“广播”:将一个向量添加到矩阵中意味着将它添加到矩阵中的每一行。因此,XW + b首先将X乘以W,得到一个每个实例一行、每个输出一列的矩阵,然后将向量b添加到该矩阵的每一行,这将使每个偏置项添加到相应的输出中,对每个实例都是如此。此外,ϕ然后逐项应用于结果矩阵中的每个项目。
那么,感知器是如何训练的呢?Rosenblatt 提出的感知器训练算法在很大程度上受到Hebb 规则的启发。在他 1949 年的书《行为的组织》(Wiley)中,Donald Hebb 建议,当一个生物神经元经常触发另一个神经元时,这两个神经元之间的连接会变得更加强大。 Siegrid Löwel 后来用引人注目的短语总结了 Hebb 的想法,“一起激活的细胞,一起连接”;也就是说,当两个神经元同时激活时,它们之间的连接权重倾向于增加。这个规则后来被称为 Hebb 规则(或Hebbian 学习)。感知器使用这个规则的变体进行训练,该规则考虑了网络在进行预测时所产生的错误;感知器学习规则加强了有助于减少错误的连接。更具体地说,感知器一次馈送一个训练实例,并为每个实例进行预测。对于每个产生错误预测的输出神经元,它加强了从输入到正确预测的贡献的连接权重。该规则显示在方程 10-3 中。
方程 10-3。感知器学习规则(权重更新)
在这个方程中:
每个输出神经元的决策边界是线性的,因此感知器无法学习复杂的模式(就像逻辑回归分类器一样)。然而,如果训练实例是线性可分的,Rosenblatt 证明了这个算法会收敛到一个解决方案。这被称为感知器收敛定理。
Scikit-Learn 提供了一个Perceptron类,可以像你期望的那样使用,例如在鸢尾花数据集上(在第四章介绍)。
import numpy as np
from sklearn.datasets import load_iris
from sklearn.linear_model import Perceptron
iris = load_iris(as_frame=True)
X = iris.data[["petal length (cm)", "petal width (cm)"]].values
y = (iris.target == 0) # Iris setosa
per_clf = Perceptron(random_state=42)
per_clf.fit(X, y)
X_new = [[2, 0.5], [3, 1]]
y_pred = per_clf.predict(X_new) # predicts True and False for these 2 flowers
您可能已经注意到感知器学习算法与随机梯度下降(在第四章介绍)非常相似。事实上,Scikit-Learn 的Perceptron类等同于使用具有以下超参数的SGDClassifier:loss="perceptron"、learning_rate="constant"、eta0=1(学习率)和penalty=None(无正则化)。
在他们 1969 年的专著感知器中,Marvin Minsky 和 Seymour Papert 强调了感知器的一些严重弱点,特别是它们无法解决一些微不足道的问题(例如异或(XOR)分类问题;请参见图 10-6 的左侧)。这也适用于任何其他线性分类模型(如逻辑回归分类器),但研究人员对感知器寄予了更高的期望,有些人对此感到如此失望,以至于完全放弃了神经网络,转而研究更高级的问题,如逻辑、问题解决和搜索。实际应用的缺乏也没有帮助。
事实证明,通过堆叠多个感知器可以消除一些感知器的限制。结果得到的人工神经网络称为多层感知器(MLP)。MLP 可以解决 XOR 问题,您可以通过计算图 10-6 右侧所代表的 MLP 的输出来验证:对于输入(0, 0)或(1, 1),网络输出为 0,对于输入(0, 1)或(1, 0),它输出为 1。尝试验证这个网络确实解决了 XOR 问题!
![mls3 1006]()
图 10-6. XOR 分类问题及解决该问题的 MLP
注意
与逻辑回归分类器相反,感知器不会输出类概率。这是偏爱逻辑回归而不是感知器的一个原因。此外,感知器默认不使用任何正则化,训练会在训练集上没有更多预测错误时停止,因此该模型通常不会像逻辑回归或线性 SVM 分类器那样泛化得很好。然而,感知器可能训练速度稍快。
多层感知器和反向传播
一个 MLP 由一个输入层、一个或多个称为隐藏层的 TLU 层以及一个称为输出层的 TLU 层组成(请参见图 10-7)。靠近输入层的层通常称为较低层,靠近输出的层通常称为较高层。
![mls3 1007]()
图 10-7. 一个具有两个输入、一个包含四个神经元的隐藏层和三个输出神经元的多层感知器的架构
注意
信号只能单向流动(从输入到输出),因此这种架构是前馈神经网络(FNN)的一个例子。
当一个人工神经网络包含深度堆叠的隐藏层时,它被称为深度神经网络(DNN)。深度学习领域研究 DNNs,更一般地,它对包含深度堆叠计算的模型感兴趣。尽管如此,许多人在涉及神经网络时都谈论深度学习(即使是浅层的)。
多年来,研究人员努力寻找一种训练 MLP 的方法,但没有成功。在 1960 年代初,一些研究人员讨论了使用梯度下降来训练神经网络的可能性,但正如我们在第四章中看到的,这需要计算模型参数的梯度与模型误差之间的关系;当时如何有效地处理这样一个包含如此多参数的复杂模型,尤其是使用当时的计算机时,这并不清楚。
然后,在 1970 年,一位名叫 Seppo Linnainmaa 的研究人员在他的硕士论文中介绍了一种自动高效计算所有梯度的技术。这个算法现在被称为反向模式自动微分(或简称反向模式自动微分)。通过网络的两次遍历(一次前向,一次后向),它能够计算神经网络中每个模型参数的误差梯度。换句话说,它可以找出如何调整每个连接权重和每个偏差以减少神经网络的误差。然后可以使用这些梯度执行梯度下降步骤。如果重复这个自动计算梯度和梯度下降步骤的过程,神经网络的误差将逐渐下降,直到最终达到最小值。这种反向模式自动微分和梯度下降的组合现在被称为反向传播(或简称反向传播)。
注意
有各种自动微分技术,各有利弊。反向模式自动微分在要求对具有许多变量(例如连接权重和偏差)和少量输出(例如一个损失)进行微分时非常适用。如果想了解更多关于自动微分的信息,请查看附录 B。
反向传播实际上可以应用于各种计算图,不仅仅是神经网络:事实上,Linnainmaa 的硕士论文并不是关于神经网络的,而是更为普遍。在反向传播开始用于训练神经网络之前,还需要几年时间,但它仍然不是主流。然后,在 1985 年,David Rumelhart、Geoffrey Hinton 和 Ronald Williams 发表了一篇开创性的论文¹⁰,分析了反向传播如何使神经网络学习到有用的内部表示。他们的结果非常令人印象深刻,以至于反向传播很快在该领域中流行起来。如今,它是迄今为止最受欢迎的神经网络训练技术。
让我们再详细介绍一下反向传播的工作原理:
-
它一次处理一个小批量(例如,每个包含 32 个实例),并多次遍历整个训练集。每次遍历称为纪元。
-
每个小批量通过输入层进入网络。然后,算法计算小批量中每个实例的第一个隐藏层中所有神经元的输出。结果传递到下一层,计算其输出并传递到下一层,依此类推,直到得到最后一层的输出,即输出层。这是前向传递:它与进行预测完全相同,只是所有中间结果都被保留,因为它们需要用于反向传递。
-
接下来,算法测量网络的输出误差(即,使用比较期望输出和网络实际输出的损失函数,并返回一些误差度量)。
-
然后计算每个输出偏差和每个连接到输出层的连接对误差的贡献。这是通过应用链式法则(可能是微积分中最基本的规则)进行分析的,使得这一步骤快速而精确。
-
然后,算法测量每个下一层中每个连接贡献的误差量,再次使用链式法则,向后工作直到达到输入层。正如前面解释的那样,这个反向传递有效地测量了网络中所有连接权重和偏差的误差梯度,通过网络向后传播误差梯度(因此算法的名称)。
-
最后,算法执行梯度下降步骤,调整网络中所有连接权重,使用刚刚计算的误差梯度。
警告
重要的是要随机初始化所有隐藏层的连接权重,否则训练将失败。例如,如果你将所有权重和偏置初始化为零,那么给定层中的所有神经元将完全相同,因此反向传播将以完全相同的方式影响它们,因此它们将保持相同。换句话说,尽管每层有数百个神经元,但你的模型将表现得好像每层只有一个神经元:它不会太聪明。相反,如果你随机初始化权重,你会打破对称,并允许反向传播训练一个多样化的神经元团队。
简而言之,反向传播对一个小批量进行预测(前向传播),测量误差,然后逆向遍历每一层以测量每个参数的误差贡献(反向传播),最后调整连接权重和偏置以减少误差(梯度下降步骤)。
为了使反向传播正常工作,Rumelhart 和他的同事对 MLP 的架构进行了关键更改:他们用逻辑函数替换了阶跃函数,σ(z) = 1 / (1 + exp(–z)),也称为 S 形函数。这是必不可少的,因为阶跃函数只包含平坦段,因此没有梯度可用(梯度下降无法在平坦表面上移动),而 S 形函数在任何地方都有明确定义的非零导数,允许梯度下降在每一步都取得一些进展。事实上,反向传播算法与许多其他激活函数一起工作得很好,不仅仅是 S 形函数。这里有另外两个流行的选择:
双曲正切函数:tanh(z) = 2σ(2z) – 1
就像 S 形函数一样,这个激活函数是S形的,连续的,可微的,但其输出值范围是-1 到 1(而不是 S 形函数的 0 到 1)。这个范围倾向于使每一层的输出在训练开始时更多或更少地集中在 0 附近,这通常有助于加快收敛速度。
修正线性单元函数:ReLU(z) = max(0, z)
ReLU 函数在z = 0 处不可微(斜率突然变化,可能导致梯度下降跳动),其导数在z < 0 时为 0。然而,在实践中,它工作得很好,并且计算速度快,因此已经成为默认选择。重要的是,它没有最大输出值有助于减少梯度下降过程中的一些问题(我们将在第十一章中回到这个问题)。
这些流行的激活函数及其导数在图 10-8 中表示。但等等!为什么我们需要激活函数呢?如果你串联几个线性变换,你得到的只是一个线性变换。例如,如果 f(x) = 2x + 3,g(x) = 5x – 1,那么串联这两个线性函数会给你另一个线性函数:f(g(x)) = 2(5x – 1) + 3 = 10x + 1。因此,如果在层之间没有一些非线性,那么即使是深层堆叠也等效于单层,你无法用它解决非常复杂的问题。相反,具有非线性激活的足够大的 DNN 在理论上可以逼近任何连续函数。
![mls3 1008]()
图 10-8。激活函数(左)及其导数(右)
好了!你知道神经网络是从哪里来的,它们的架构是什么,以及如何计算它们的输出。你也学到了反向传播算法。但神经网络到底能做什么呢?
回归 MLP
首先,MLP 可以用于回归任务。如果要预测单个值(例如,给定房屋的许多特征,预测房屋的价格),则只需一个输出神经元:其输出是预测值。对于多变量回归(即一次预测多个值),您需要每个输出维度一个输出神经元。例如,要在图像中定位对象的中心,您需要预测 2D 坐标,因此需要两个输出神经元。如果还想在对象周围放置一个边界框,则需要另外两个数字:对象的宽度和高度。因此,您最终会得到四个输出神经元。
Scikit-Learn 包括一个MLPRegressor类,让我们使用它来构建一个 MLP,其中包含三个隐藏层,每个隐藏层由 50 个神经元组成,并在加利福尼亚房屋数据集上进行训练。为简单起见,我们将使用 Scikit-Learn 的fetch_california_housing()函数来加载数据。这个数据集比我们在第二章中使用的数据集简单,因为它只包含数值特征(没有ocean_proximity特征),并且没有缺失值。以下代码首先获取并拆分数据集,然后创建一个管道来标准化输入特征,然后将它们发送到MLPRegressor。这对于神经网络非常重要,因为它们是使用梯度下降进行训练的,正如我们在第四章中看到的,当特征具有非常不同的尺度时,梯度下降不会收敛得很好。最后,代码训练模型并评估其验证错误。该模型在隐藏层中使用 ReLU 激活函数,并使用一种称为Adam的梯度下降变体(参见第十一章)来最小化均方误差,还有一点ℓ[2]正则化(您可以通过alpha超参数来控制):
from sklearn.datasets import fetch_california_housing
from sklearn.metrics import mean_squared_error
from sklearn.model_selection import train_test_split
from sklearn.neural_network import MLPRegressor
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
housing = fetch_california_housing()
X_train_full, X_test, y_train_full, y_test = train_test_split(
housing.data, housing.target, random_state=42)
X_train, X_valid, y_train, y_valid = train_test_split(
X_train_full, y_train_full, random_state=42)
mlp_reg = MLPRegressor(hidden_layer_sizes=[50, 50, 50], random_state=42)
pipeline = make_pipeline(StandardScaler(), mlp_reg)
pipeline.fit(X_train, y_train)
y_pred = pipeline.predict(X_valid)
rmse = mean_squared_error(y_valid, y_pred, squared=False) # about 0.505
我们得到了约 0.505 的验证 RMSE,这与使用随机森林分类器得到的结果相当。对于第一次尝试来说,这还不错!
请注意,此 MLP 不使用任何激活函数用于输出层,因此可以自由输出任何值。这通常没问题,但是如果要确保输出始终为正值,则应在输出层中使用 ReLU 激活函数,或者使用softplus激活函数,它是 ReLU 的平滑变体:softplus(z) = log(1 + exp(z))。当z为负时,softplus 接近 0,当z为正时,softplus 接近z。最后,如果要确保预测始终落在给定值范围内,则应使用 sigmoid 函数或双曲正切,并将目标缩放到适当的范围:sigmoid 为 0 到 1,tanh 为-1 到 1。遗憾的是,MLPRegressor类不支持输出层中的激活函数。
警告
在几行代码中使用 Scikit-Learn 构建和训练标准 MLP 非常方便,但神经网络的功能有限。这就是为什么我们将在本章的第二部分切换到 Keras 的原因。
MLPRegressor类使用均方误差,这通常是回归任务中想要的,但是如果训练集中有很多异常值,您可能更喜欢使用平均绝对误差。或者,您可能希望使用Huber 损失,它是两者的组合。当误差小于阈值δ(通常为 1)时,它是二次的,但是当误差大于δ时,它是线性的。线性部分使其对异常值不太敏感,而二次部分使其比平均绝对误差更快收敛并更精确。但是,MLPRegressor只支持 MSE。
表 10-1 总结了回归 MLP 的典型架构。
表 10-1. 典型的回归 MLP 架构
| 超参数 |
典型值 |
| #隐藏层 |
取决于问题,但通常为 1 到 5 |
| #每个隐藏层的神经元数 |
取决于问题,但通常为 10 到 100 |
| #输出神经元 |
每个预测维度 1 个 |
| 隐藏激活 |
ReLU |
| 输出激活 |
无,或 ReLU/softplus(如果是正输出)或 sigmoid/tanh(如果是有界输出) |
| 损失函数 |
MSE,或者如果有异常值则为 Huber |
分类 MLP
MLP 也可以用于分类任务。对于二元分类问题,您只需要一个使用 sigmoid 激活函数的输出神经元:输出将是 0 到 1 之间的数字,您可以将其解释为正类的估计概率。负类的估计概率等于 1 减去该数字。
MLP 也可以轻松处理多标签二元分类任务(参见第三章)。例如,您可以有一个电子邮件分类系统,预测每封传入的电子邮件是垃圾邮件还是正常邮件,并同时预测它是紧急还是非紧急邮件。在这种情况下,您需要两个输出神经元,都使用 sigmoid 激活函数:第一个将输出电子邮件是垃圾邮件的概率,第二个将输出它是紧急邮件的概率。更一般地,您将为每个正类分配一个输出神经元。请注意,输出概率不一定相加为 1。这使模型可以输出任何标签组合:您可以有非紧急的正常邮件、紧急的正常邮件、非紧急的垃圾邮件,甚至可能是紧急的垃圾邮件(尽管那可能是一个错误)。
如果每个实例只能属于一个类别,且有三个或更多可能的类别(例如,数字图像分类中的类别 0 到 9),那么您需要每个类别一个输出神经元,并且应该为整个输出层使用 softmax 激活函数(参见图 10-9)。Softmax 函数(在第四章介绍)将确保所有估计的概率在 0 和 1 之间,并且它们相加为 1,因为类别是互斥的。正如您在第三章中看到的,这被称为多类分类。
关于损失函数,由于我们正在预测概率分布,交叉熵损失(或x-熵或简称对数损失,参见第四章)通常是一个不错的选择。
![mls3 1009]()
图 10-9。用于分类的现代 MLP(包括 ReLU 和 softmax)
Scikit-Learn 在sklearn.neural_network包中有一个MLPClassifier类。它几乎与MLPRegressor类相同,只是它最小化交叉熵而不是均方误差。现在尝试一下,例如在鸢尾花数据集上。这几乎是一个线性任务,因此一个具有 5 到 10 个神经元的单层应该足够(确保对特征进行缩放)。
表 10-2 总结了分类 MLP 的典型架构。
表 10-2。典型的分类 MLP 架构
| 超参数 |
二元分类 |
多标签二元分类 |
多类分类 |
| #隐藏层 |
通常为 1 到 5 层,取决于任务 |
|
|
| #输出神经元 |
1 |
每个二元标签 1 个 |
每个类别 1 个 |
| 输出层激活 |
Sigmoid |
Sigmoid |
Softmax |
| 损失函数 |
X-熵 |
X-熵 |
X-熵 |
提示
在继续之前,我建议您完成本章末尾的练习 1。您将尝试各种神经网络架构,并使用TensorFlow playground可视化它们的输出。这将非常有助于更好地理解 MLP,包括所有超参数(层数和神经元数量、激活函数等)的影响。
现在您已经掌握了开始使用 Keras 实现 MLP 所需的所有概念!
使用 Keras 实现 MLP
Keras 是 TensorFlow 的高级深度学习 API:它允许您构建、训练、评估和执行各种神经网络。最初,Keras 库是由 François Chollet 作为研究项目的一部分开发的¹²,并于 2015 年 3 月作为一个独立的开源项目发布。由于其易用性、灵活性和美观的设计,它很快就受到了欢迎。
注意
Keras 曾支持多个后端,包括 TensorFlow、PlaidML、Theano 和 Microsoft Cognitive Toolkit(CNTK)(最后两个遗憾地已弃用),但自版本 2.4 以来,Keras 仅支持 TensorFlow。同样,TensorFlow 曾包括多个高级 API,但在 TensorFlow 2 发布时,Keras 被正式选择为其首选的高级 API。安装 TensorFlow 将自动安装 Keras,并且没有安装 TensorFlow,Keras 将无法工作。简而言之,Keras 和 TensorFlow 相爱并结为夫妻。其他流行的深度学习库包括Facebook 的 PyTorch和Google 的 JAX。¹³
现在让我们使用 Keras!我们将首先构建一个用于图像分类的 MLP。
注意
Colab 运行时已预装了最新版本的 TensorFlow 和 Keras。但是,如果您想在自己的机器上安装它们,请参阅https://homl.info/install上的安装说明。
使用顺序 API 构建图像分类器
首先,我们需要加载一个数据集。我们将使用时尚 MNIST,它是 MNIST 的一个替代品(在第三章介绍)。它与 MNIST 具有完全相同的格式(70,000 个 28×28 像素的灰度图像,共 10 个类),但图像代表时尚物品而不是手写数字,因此每个类更加多样化,问题变得比 MNIST 更具挑战性。例如,一个简单的线性模型在 MNIST 上达到约 92%的准确率,但在时尚 MNIST 上只有约 83%。
使用 Keras 加载数据集
Keras 提供了一些实用函数来获取和加载常见数据集,包括 MNIST、时尚 MNIST 等。让我们加载时尚 MNIST。它已经被洗牌并分成一个训练集(60,000 张图片)和一个测试集(10,000 张图片),但我们将从训练集中保留最后的 5,000 张图片用于验证:
import tensorflow as tf
fashion_mnist = tf.keras.datasets.fashion_mnist.load_data()
(X_train_full, y_train_full), (X_test, y_test) = fashion_mnist
X_train, y_train = X_train_full[:-5000], y_train_full[:-5000]
X_valid, y_valid = X_train_full[-5000:], y_train_full[-5000:]
提示
TensorFlow 通常被导入为tf,Keras API 可通过tf.keras使用。
使用 Keras 加载 MNIST 或时尚 MNIST 时,与 Scikit-Learn 相比的一个重要区别是,每个图像都表示为一个 28×28 的数组,而不是大小为 784 的一维数组。此外,像素强度表示为整数(从 0 到 255),而不是浮点数(从 0.0 到 255.0)。让我们看看训练集的形状和数据类型:
>>> X_train.shape
(55000, 28, 28)
>>> X_train.dtype
dtype('uint8')
为简单起见,我们将通过将它们除以 255.0 来将像素强度缩放到 0-1 范围(这也将它们转换为浮点数):
X_train, X_valid, X_test = X_train / 255., X_valid / 255., X_test / 255.
对于 MNIST,当标签等于 5 时,这意味着图像代表手写数字 5。简单。然而,对于时尚 MNIST,我们需要类名列表以了解我们正在处理的内容:
class_names = ["T-shirt/top", "Trouser", "Pullover", "Dress", "Coat",
"Sandal", "Shirt", "Sneaker", "Bag", "Ankle boot"]
例如,训练集中的第一张图像代表一个踝靴:
>>> class_names[y_train[0]]
'Ankle boot'
图 10-10 显示了时尚 MNIST 数据集的一些样本。
![mls3 1010]()
图 10-10。时尚 MNIST 的样本
使用顺序 API 创建模型
现在让我们构建神经网络!这是一个具有两个隐藏层的分类 MLP:
tf.random.set_seed(42)
model = tf.keras.Sequential()
model.add(tf.keras.layers.Input(shape=[28, 28]))
model.add(tf.keras.layers.Flatten())
model.add(tf.keras.layers.Dense(300, activation="relu"))
model.add(tf.keras.layers.Dense(100, activation="relu"))
model.add(tf.keras.layers.Dense(10, activation="softmax"))
让我们逐行查看这段代码:
-
首先,设置 TensorFlow 的随机种子以使结果可重现:每次运行笔记本时,隐藏层和输出层的随机权重将保持相同。您还可以选择使用 tf.keras.utils.set_random_seed() 函数,它方便地为 TensorFlow、Python (random.seed()) 和 NumPy (np.random.seed()) 设置随机种子。
-
下一行创建一个 Sequential 模型。这是 Keras 模型中最简单的一种,用于仅由一系列按顺序连接的层组成的神经网络。这被称为顺序 API。
-
接下来,我们构建第一层(一个 Input 层)并将其添加到模型中。我们指定输入的 shape,它不包括批量大小,只包括实例的形状。Keras 需要知道输入的形状,以便确定第一个隐藏层的连接权重矩阵的形状。
-
然后我们添加一个 Flatten 层。它的作用是将每个输入图像转换为 1D 数组:例如,如果它接收到一个形状为 [32, 28, 28] 的批量,它将将其重塑为 [32, 784]。换句话说,如果它接收到输入数据 X,它会计算 X.reshape(-1, 784)。这个层没有任何参数;它只是用来进行一些简单的预处理。
-
接下来我们添加一个具有 300 个神经元的 Dense 隐藏层。它将使用 ReLU 激活函数。每个 Dense 层都管理着自己的权重矩阵,其中包含神经元与它们的输入之间的所有连接权重。它还管理着一个偏置项向量(每个神经元一个)。当它接收到一些输入数据时,它会计算 方程 10-2。
-
然后我们添加一个具有 100 个神经元的第二个 Dense 隐藏层,同样使用 ReLU 激活函数。
-
最后,我们添加一个具有 10 个神经元(每个类一个)的 Dense 输出层,使用 softmax 激活函数,因为类是互斥的。
提示
指定 activation="relu" 等同于指定 activation=tf.keras.activations.relu。其他激活函数可以在 tf.keras.activations 包中找到。我们将在本书中使用许多这些激活函数;请参阅 https://keras.io/api/layers/activations 获取完整列表。我们还将在 第十二章 中定义我们自己的自定义激活函数。
与刚刚逐个添加层不同,通常更方便的做法是在创建 Sequential 模型时传递一个层列表。您还可以删除 Input 层,而是在第一层中指定 input_shape:
model = tf.keras.Sequential([
tf.keras.layers.Flatten(input_shape=[28, 28]),
tf.keras.layers.Dense(300, activation="relu"),
tf.keras.layers.Dense(100, activation="relu"),
tf.keras.layers.Dense(10, activation="softmax")
])
模型的 summary() 方法显示了所有模型的层,包括每个层的名称(除非在创建层时设置了名称,否则会自动生成),其输出形状(None 表示批量大小可以是任意值),以及其参数数量。摘要以总参数数量结束,包括可训练和不可训练参数。在这里我们只有可训练参数(您将在本章后面看到一些不可训练参数):
>>> model.summary()
Model: "sequential"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
flatten (Flatten) (None, 784) 0
dense (Dense) (None, 300) 235500
dense_1 (Dense) (None, 100) 30100
dense_2 (Dense) (None, 10) 1010
=================================================================
Total params: 266,610
Trainable params: 266,610
Non-trainable params: 0
_________________________________________________________________
注意,Dense 层通常具有大量参数。例如,第一个隐藏层有 784 × 300 个连接权重,再加上 300 个偏置项,总共有 235,500 个参数!这使得模型具有相当大的灵活性来拟合训练数据,但也意味着模型有过拟合的风险,特别是当训练数据不多时。我们稍后会回到这个问题。
模型中的每个层必须具有唯一的名称(例如,"dense_2")。您可以使用构造函数的name参数显式设置层名称,但通常最好让 Keras 自动命名层,就像我们刚刚做的那样。Keras 获取层的类名并将其转换为蛇形命名法(例如,MyCoolLayer类的层默认命名为"my_cool_layer")。Keras 还确保名称在全局范围内是唯一的,即使跨模型也是如此,如果需要,会附加索引,例如"dense_2"。但是为什么要确保名称在模型之间是唯一的呢?这样可以轻松合并模型而不会出现名称冲突。
提示
Keras 管理的所有全局状态都存储在Keras 会话中,您可以使用tf.keras.backend.clear_session()清除它。特别是,这将重置名称计数器。
您可以使用layers属性轻松获取模型的层列表,或使用get_layer()方法按名称访问层:
>>> model.layers
[<keras.layers.core.flatten.Flatten at 0x7fa1dea02250>,
<keras.layers.core.dense.Dense at 0x7fa1c8f42520>,
<keras.layers.core.dense.Dense at 0x7fa188be7ac0>,
<keras.layers.core.dense.Dense at 0x7fa188be7fa0>]
>>> hidden1 = model.layers[1]
>>> hidden1.name
'dense'
>>> model.get_layer('dense') is hidden1
True
可以使用其get_weights()和set_weights()方法访问层的所有参数。对于Dense层,这包括连接权重和偏差项:
>>> weights, biases = hidden1.get_weights()
>>> weights
array([[ 0.02448617, -0.00877795, -0.02189048, ..., 0.03859074, -0.06889391],
[ 0.00476504, -0.03105379, -0.0586676 , ..., -0.02763776, -0.04165364],
...,
[ 0.07061854, -0.06960931, 0.07038955, ..., 0.00034875, 0.02878492],
[-0.06022581, 0.01577859, -0.02585464, ..., 0.00272203, -0.06793761]],
dtype=float32)
>>> weights.shape
(784, 300)
>>> biases
array([0., 0., 0., 0., 0., 0., 0., 0., 0., ..., 0., 0., 0.], dtype=float32)
>>> biases.shape
(300,)
请注意,Dense层随机初始化连接权重(这是为了打破对称性,如前所述),偏差初始化为零,这是可以的。如果要使用不同的初始化方法,可以在创建层时设置kernel_initializer(kernel是连接权重矩阵的另一个名称)或bias_initializer。我们将在第十一章进一步讨论初始化器,完整列表在https://keras.io/api/layers/initializers。
注意
权重矩阵的形状取决于输入的数量,这就是为什么在创建模型时我们指定了input_shape。如果您没有指定输入形状,没关系:Keras 会等到知道输入形状后才真正构建模型参数。这将在您提供一些数据(例如,在训练期间)或调用其build()方法时发生。在模型参数构建之前,您将无法执行某些操作,例如显示模型摘要或保存模型。因此,如果在创建模型时知道输入形状,最好指定它。
编译模型
创建模型后,必须调用其compile()方法来指定要使用的损失函数和优化器。可选地,您可以指定在训练和评估过程中计算的额外指标列表:
model.compile(loss="sparse_categorical_crossentropy",
optimizer="sgd",
metrics=["accuracy"])
注意
使用loss="sparse_categorical_crossentropy"等同于使用loss=tf.keras.losses.sparse_categorical_crossentropy。同样,使用optimizer="sgd"等同于使用optimizer=tf.keras.optimizers.SGD(),使用metrics=["accuracy"]等同于使用metrics=[tf.keras.metrics.sparse_categorical_accuracy](使用此损失时)。在本书中,我们将使用许多其他损失、优化器和指标;有关完整列表,请参见https://keras.io/api/losses、https://keras.io/api/optimizers和https://keras.io/api/metrics。
这段代码需要解释。我们使用"sparse_categorical_crossentropy"损失,因为我们有稀疏标签(即,对于每个实例,只有一个目标类索引,本例中为 0 到 9),并且类是互斥的。如果相反,对于每个实例有一个目标概率类(例如,独热向量,例如,[0., 0., 0., 1., 0., 0., 0., 0., 0., 0.]表示类 3),那么我们需要使用"categorical_crossentropy"损失。如果我们进行二元分类或多标签二元分类,则在输出层中使用"sigmoid"激活函数,而不是"softmax"激活函数,并且我们将使用"binary_crossentropy"损失。
提示
如果你想将稀疏标签(即类别索引)转换为独热向量标签,请使用tf.keras.utils.to_categorical()函数。要反过来,使用带有axis=1的np.argmax()函数。
关于优化器,"sgd"表示我们将使用随机梯度下降来训练模型。换句话说,Keras 将执行前面描述的反向传播算法(即反向模式自动微分加梯度下降)。我们将在第十一章中讨论更高效的优化器。它们改进了梯度下降,而不是自动微分。
注意
当使用SGD优化器时,调整学习率是很重要的。因此,通常你会想要使用optimizer=tf.keras.optimizers.SGD(learning_rate=__???__)来设置学习率,而不是optimizer="sgd",后者默认学习率为 0.01。
最后,由于这是一个分类器,所以在训练和评估过程中测量其准确性是有用的,这就是为什么我们设置metrics=["accuracy"]。
训练和评估模型
现在模型已经准备好进行训练了。为此,我们只需要调用它的fit()方法:
>>> history = model.fit(X_train, y_train, epochs=30,
... validation_data=(X_valid, y_valid))
...
Epoch 1/30
1719/1719 [==============================] - 2s 989us/step
- loss: 0.7220 - sparse_categorical_accuracy: 0.7649
- val_loss: 0.4959 - val_sparse_categorical_accuracy: 0.8332
Epoch 2/30
1719/1719 [==============================] - 2s 964us/step
- loss: 0.4825 - sparse_categorical_accuracy: 0.8332
- val_loss: 0.4567 - val_sparse_categorical_accuracy: 0.8384
[...]
Epoch 30/30
1719/1719 [==============================] - 2s 963us/step
- loss: 0.2235 - sparse_categorical_accuracy: 0.9200
- val_loss: 0.3056 - val_sparse_categorical_accuracy: 0.8894
我们传递输入特征(X_train)和目标类别(y_train),以及训练的时期数量(否则默认为 1,这绝对不足以收敛到一个好的解决方案)。我们还传递一个验证集(这是可选的)。Keras 将在每个时期结束时在这个集合上测量损失和额外的指标,这对于查看模型的实际表现非常有用。如果在训练集上的表现比在验证集上好得多,那么你的模型可能过度拟合训练集,或者存在错误,比如训练集和验证集之间的数据不匹配。
提示
形状错误是非常常见的,特别是在刚开始时,所以你应该熟悉错误消息:尝试用错误形状的输入和/或标签拟合模型,看看你得到的错误。同样,尝试用loss="categorical_crossentropy"而不是loss="sparse_categorical_crossentropy"来编译模型。或者你可以移除Flatten层。
就是这样!神经网络已经训练好了。在训练过程中的每个时期,Keras 会在进度条的左侧显示迄今为止处理的小批量数量。批量大小默认为 32,由于训练集有 55,000 张图像,模型每个时期会经过 1,719 个批次:1,718 个大小为 32,1 个大小为 24。在进度条之后,你可以看到每个样本的平均训练时间,以及训练集和验证集上的损失和准确性(或者你要求的任何其他额外指标)。请注意,训练损失下降了,这是一个好迹象,验证准确性在 30 个时期后达到了 88.94%。这略低于训练准确性,所以有一点过拟合,但不是很严重。
提示
不要使用validation_data参数传递验证集,你可以将validation_split设置为你希望 Keras 用于验证的训练集比例。例如,validation_split=0.1告诉 Keras 使用数据的最后 10%(在洗牌之前)作为验证集。
如果训练集非常倾斜,某些类别过度表示,而其他类别则表示不足,那么在调用 fit() 方法时设置 class_weight 参数会很有用,以给予少数类别更大的权重,而给予多数类别更小的权重。这些权重将在计算损失时由 Keras 使用。如果需要每个实例的权重,可以设置 sample_weight 参数。如果同时提供了 class_weight 和 sample_weight,那么 Keras 会将它们相乘。每个实例的权重可能很有用,例如,如果一些实例由专家标记,而其他实例使用众包平台标记:你可能希望给前者更多的权重。您还可以为验证集提供样本权重(但不是类别权重),方法是将它们作为 validation_data 元组的第三个项目添加。
fit() 方法返回一个 History 对象,其中包含训练参数 (history.params)、经历的每个 epoch 的列表 (history.epoch),最重要的是一个字典 (history.history),其中包含每个 epoch 结束时在训练集和验证集(如果有的话)上测量的损失和额外指标。如果使用这个字典创建一个 Pandas DataFrame,并调用它的 plot() 方法,就可以得到 Figure 10-11 中显示的学习曲线:
import matplotlib.pyplot as plt
import pandas as pd
pd.DataFrame(history.history).plot(
figsize=(8, 5), xlim=[0, 29], ylim=[0, 1], grid=True, xlabel="Epoch",
style=["r--", "r--.", "b-", "b-*"])
plt.show()
![mls3 1011]()
图 10-11. 学习曲线:每个 epoch 结束时测量的平均训练损失和准确率,以及每个 epoch 结束时测量的平均验证损失和准确率
您可以看到,在训练过程中,训练准确率和验证准确率都在稳步增加,而训练损失和验证损失都在减少。这是好的。验证曲线在开始时相对接近,但随着时间的推移,它们之间的差距变得更大,这表明存在一些过拟合。在这种特殊情况下,模型在训练开始阶段在验证集上的表现似乎比在训练集上好,但实际情况并非如此。验证错误是在 每个 epoch 结束时计算的,而训练错误是在 每个 epoch 期间 使用运行平均值计算的,因此训练曲线应该向左移动半个 epoch。如果这样做,您会看到在训练开始阶段,训练和验证曲线几乎完美重合。
训练集的性能最终会超过验证集的性能,这通常是在训练足够长时间后的情况。你可以看出模型还没有完全收敛,因为验证损失仍在下降,所以你可能应该继续训练。只需再次调用 fit() 方法,因为 Keras 会从离开的地方继续训练:你应该能够达到约 89.8% 的验证准确率,而训练准确率将继续上升到 100%(这并不总是情况)。
如果你对模型的性能不满意,你应该回去调整超参数。首先要检查的是学习率。如果这没有帮助,尝试另一个优化器(并在更改任何超参数后重新调整学习率)。如果性能仍然不理想,那么尝试调整模型超参数,如层数、每层神经元的数量以及每个隐藏层要使用的激活函数类型。你也可以尝试调整其他超参数,比如批量大小(可以在fit()方法中使用batch_size参数设置,默认为 32)。我们将在本章末回到超参数调整。一旦你对模型的验证准确率感到满意,你应该在部署模型到生产环境之前在测试集上评估它以估计泛化误差。你可以使用evaluate()方法轻松实现这一点(它还支持其他几个参数,如batch_size和sample_weight;请查看文档以获取更多详细信息):
>>> model.evaluate(X_test, y_test)
313/313 [==============================] - 0s 626us/step
- loss: 0.3243 - sparse_categorical_accuracy: 0.8864
[0.32431697845458984, 0.8863999843597412]
正如你在第二章中看到的,通常在测试集上的性能会略低于验证集,因为超参数是在验证集上调整的,而不是在测试集上(然而,在这个例子中,我们没有进行任何超参数调整,所以较低的准确率只是运气不佳)。记住要抵制在测试集上调整超参数的诱惑,否则你对泛化误差的估计将会过于乐观。
使用模型进行预测
现在让我们使用模型的predict()方法对新实例进行预测。由于我们没有实际的新实例,我们将只使用测试集的前三个实例:
>>> X_new = X_test[:3]
>>> y_proba = model.predict(X_new)
>>> y_proba.round(2)
array([[0\. , 0\. , 0\. , 0\. , 0\. , 0.01, 0\. , 0.02, 0\. , 0.97],
[0\. , 0\. , 0.99, 0\. , 0.01, 0\. , 0\. , 0\. , 0\. , 0\. ],
[0\. , 1\. , 0\. , 0\. , 0\. , 0\. , 0\. , 0\. , 0\. , 0\. ]],
dtype=float32)
对于每个实例,模型会为每个类别(从类别 0 到类别 9)估计一个概率。这类似于 Scikit-Learn 分类器中predict_proba()方法的输出。例如,对于第一幅图像,它估计类别 9(踝靴)的概率为 96%,类别 7(运动鞋)的概率为 2%,类别 5(凉鞋)的概率为 1%,其他类别的概率可以忽略不计。换句话说,它非常确信第一幅图像是鞋类,很可能是踝靴,但也可能是运动鞋或凉鞋。如果你只关心估计概率最高的类别(即使概率很低),那么你可以使用argmax()方法来获取每个实例的最高概率类别索引:
>>> import numpy as np
>>> y_pred = y_proba.argmax(axis=-1)
>>> y_pred
array([9, 2, 1])
>>> np.array(class_names)[y_pred]
array(['Ankle boot', 'Pullover', 'Trouser'], dtype='<U11')
在这里,分类器实际上正确分类了所有三幅图像(这些图像显示在图 10-12 中):
>>> y_new = y_test[:3]
>>> y_new
array([9, 2, 1], dtype=uint8)
![mls3 1012]()
图 10-12。正确分类的时尚 MNIST 图像
现在你知道如何使用 Sequential API 构建、训练和评估分类 MLP 了。但是回归呢?
使用 Sequential API 构建回归 MLP
让我们回到加利福尼亚房屋问题,并使用与之前相同的 MLP,由 3 个每层 50 个神经元组成的隐藏层,但这次使用 Keras 构建它。
使用顺序 API 构建、训练、评估和使用回归 MLP 与分类问题的操作非常相似。以下代码示例中的主要区别在于输出层只有一个神经元(因为我们只想预测一个值),并且没有使用激活函数,损失函数是均方误差,度量标准是 RMSE,我们使用了像 Scikit-Learn 的MLPRegressor一样的 Adam 优化器。此外,在这个例子中,我们不需要Flatten层,而是使用Normalization层作为第一层:它执行的操作与 Scikit-Learn 的StandardScaler相同,但必须使用其adapt()方法拟合训练数据之前调用模型的fit()方法。 (Keras 还有其他预处理层,将在第十三章中介绍)。让我们来看一下:
tf.random.set_seed(42)
norm_layer = tf.keras.layers.Normalization(input_shape=X_train.shape[1:])
model = tf.keras.Sequential([
norm_layer,
tf.keras.layers.Dense(50, activation="relu"),
tf.keras.layers.Dense(50, activation="relu"),
tf.keras.layers.Dense(50, activation="relu"),
tf.keras.layers.Dense(1)
])
optimizer = tf.keras.optimizers.Adam(learning_rate=1e-3)
model.compile(loss="mse", optimizer=optimizer, metrics=["RootMeanSquaredError"])
norm_layer.adapt(X_train)
history = model.fit(X_train, y_train, epochs=20,
validation_data=(X_valid, y_valid))
mse_test, rmse_test = model.evaluate(X_test, y_test)
X_new = X_test[:3]
y_pred = model.predict(X_new)
注意
当您调用adapt()方法时,Normalization层会学习训练数据中的特征均值和标准差。然而,当您显示模型的摘要时,这些统计数据被列为不可训练的。这是因为这些参数不受梯度下降的影响。
正如您所看到的,顺序 API 非常清晰和简单。然而,虽然Sequential模型非常常见,但有时构建具有更复杂拓扑结构或多个输入或输出的神经网络是很有用的。为此,Keras 提供了功能 API。
使用功能 API 构建复杂模型
非顺序神经网络的一个例子是Wide & Deep神经网络。这种神经网络架构是由 Heng-Tze Cheng 等人在 2016 年的一篇论文中介绍的。它直接连接所有或部分输入到输出层,如图 10-13 所示。这种架构使得神经网络能够学习深层模式(使用深层路径)和简单规则(通过短路径)。相比之下,常规的 MLP 强制所有数据通过完整的层堆栈流动;因此,数据中的简单模式可能会被这一系列转换所扭曲。
![mls3 1013]()
图 10-13。Wide & Deep 神经网络
让我们构建这样一个神经网络来解决加利福尼亚房屋问题:
normalization_layer = tf.keras.layers.Normalization()
hidden_layer1 = tf.keras.layers.Dense(30, activation="relu")
hidden_layer2 = tf.keras.layers.Dense(30, activation="relu")
concat_layer = tf.keras.layers.Concatenate()
output_layer = tf.keras.layers.Dense(1)
input_ = tf.keras.layers.Input(shape=X_train.shape[1:])
normalized = normalization_layer(input_)
hidden1 = hidden_layer1(normalized)
hidden2 = hidden_layer2(hidden1)
concat = concat_layer([normalized, hidden2])
output = output_layer(concat)
model = tf.keras.Model(inputs=[input_], outputs=[output])
在高层次上,前五行创建了构建模型所需的所有层,接下来的六行使用这些层就像函数一样从输入到输出,最后一行通过指向输入和输出创建了一个 Keras Model对象。让我们更详细地看一下这段代码:
-
首先,我们创建五个层:一个Normalization层用于标准化输入,两个具有 30 个神经元的Dense层,使用 ReLU 激活函数,一个Concatenate层,以及一个没有任何激活函数的单个神经元的输出层的Dense层。
-
接下来,我们创建一个Input对象(变量名input_用于避免遮蔽 Python 内置的input()函数)。这是模型将接收的输入类型的规范,包括其shape和可选的dtype,默认为 32 位浮点数。一个模型实际上可能有多个输入,您很快就会看到。
-
然后,我们像使用函数一样使用Normalization层,将其传递给Input对象。这就是为什么这被称为功能 API。请注意,我们只是告诉 Keras 应该如何连接这些层;实际上还没有处理任何数据,因为Input对象只是一个数据规范。换句话说,它是一个符号输入。这个调用的输出也是符号的:normalized不存储任何实际数据,它只是用来构建模型。
-
同样,我们将normalized传递给hidden_layer1,输出hidden1,然后将hidden1传递给hidden_layer2,输出hidden2。
-
到目前为止,我们已经按顺序连接了层,然后使用concat_layer将输入和第二个隐藏层的输出连接起来。再次强调,实际数据尚未连接:这都是符号化的,用于构建模型。
-
然后我们将concat传递给output_layer,这给我们最终的output。
-
最后,我们创建一个 KerasModel,指定要使用的输入和输出。
构建了这个 Keras 模型之后,一切都和之前一样,所以这里不需要重复:编译模型,调整Normalization层,拟合模型,评估模型,并用它进行预测。
但是,如果您想通过宽路径发送一部分特征,并通过深路径发送另一部分特征(可能有重叠),如图 10-14 所示呢?在这种情况下,一个解决方案是使用多个输入。例如,假设我们想通过宽路径发送五个特征(特征 0 到 4),并通过深路径发送六个特征(特征 2 到 7)。我们可以这样做:
input_wide = tf.keras.layers.Input(shape=[5]) # features 0 to 4
input_deep = tf.keras.layers.Input(shape=[6]) # features 2 to 7
norm_layer_wide = tf.keras.layers.Normalization()
norm_layer_deep = tf.keras.layers.Normalization()
norm_wide = norm_layer_wide(input_wide)
norm_deep = norm_layer_deep(input_deep)
hidden1 = tf.keras.layers.Dense(30, activation="relu")(norm_deep)
hidden2 = tf.keras.layers.Dense(30, activation="relu")(hidden1)
concat = tf.keras.layers.concatenate([norm_wide, hidden2])
output = tf.keras.layers.Dense(1)(concat)
model = tf.keras.Model(inputs=[input_wide, input_deep], outputs=[output])
![mls3 1014]()
图 10-14。处理多个输入
在这个例子中,与之前的例子相比,有几点需要注意:
-
每个Dense层都是在同一行上创建并调用的。这是一种常见的做法,因为它使代码更简洁而不失清晰度。但是,我们不能对Normalization层这样做,因为我们需要对该层进行引用,以便在拟合模型之前调用其adapt()方法。
-
我们使用了tf.keras.layers.concatenate(),它创建了一个Concatenate层,并使用给定的输入调用它。
-
在创建模型时,我们指定了inputs=[input_wide, input_deep],因为有两个输入。
现在我们可以像往常一样编译模型,但是在调用fit()方法时,不是传递单个输入矩阵X_train,而是必须传递一对矩阵(X_train_wide, X_train_deep),每个输入一个。对于X_valid,以及在调用evaluate()或predict()时的X_test和X_new也是如此:
optimizer = tf.keras.optimizers.Adam(learning_rate=1e-3)
model.compile(loss="mse", optimizer=optimizer, metrics=["RootMeanSquaredError"])
X_train_wide, X_train_deep = X_train[:, :5], X_train[:, 2:]
X_valid_wide, X_valid_deep = X_valid[:, :5], X_valid[:, 2:]
X_test_wide, X_test_deep = X_test[:, :5], X_test[:, 2:]
X_new_wide, X_new_deep = X_test_wide[:3], X_test_deep[:3]
norm_layer_wide.adapt(X_train_wide)
norm_layer_deep.adapt(X_train_deep)
history = model.fit((X_train_wide, X_train_deep), y_train, epochs=20,
validation_data=((X_valid_wide, X_valid_deep), y_valid))
mse_test = model.evaluate((X_test_wide, X_test_deep), y_test)
y_pred = model.predict((X_new_wide, X_new_deep))
提示
您可以传递一个字典{"input_wide": X_train_wide, "input_deep": X_train_deep},而不是传递一个元组(X_train_wide, X_train_deep),如果在创建输入时设置了name="input_wide"和name="input_deep"。当有多个输入时,这是非常推荐的,可以澄清代码并避免顺序错误。
还有许多用例需要多个输出:
-
任务可能需要这样做。例如,您可能希望在图片中定位和分类主要对象。这既是一个回归任务,也是一个分类任务。
-
同样,您可能有基于相同数据的多个独立任务。当然,您可以为每个任务训练一个神经网络,但在许多情况下,通过训练一个单一神经网络,每个任务一个输出,您将在所有任务上获得更好的结果。这是因为神经网络可以学习数据中对所有任务都有用的特征。例如,您可以对面部图片执行多任务分类,使用一个输出来对人的面部表情(微笑,惊讶等)进行分类,另一个输出用于识别他们是否戴眼镜。
-
另一个用例是作为正则化技术(即,一种训练约束,其目标是减少过拟合,从而提高模型的泛化能力)。例如,您可能希望在神经网络架构中添加一个辅助输出(参见图 10-15),以确保网络的基础部分自己学到一些有用的东西,而不依赖于网络的其余部分。
![mls3 1015]()
图 10-15。处理多个输出,在这个例子中添加一个辅助输出进行正则化
添加额外的输出非常容易:我们只需将其连接到适当的层并将其添加到模型的输出列表中。例如,以下代码构建了图 10-15 中表示的网络:
[...] # Same as above, up to the main output layer
output = tf.keras.layers.Dense(1)(concat)
aux_output = tf.keras.layers.Dense(1)(hidden2)
model = tf.keras.Model(inputs=[input_wide, input_deep],
outputs=[output, aux_output])
每个输出都需要自己的损失函数。因此,当我们编译模型时,应该传递一个损失列表。如果我们传递一个单一损失,Keras 将假定所有输出都必须使用相同的损失。默认情况下,Keras 将计算所有损失并简单地将它们相加以获得用于训练的最终损失。由于我们更关心主要输出而不是辅助输出(因为它仅用于正则化),我们希望给主要输出的损失分配更大的权重。幸运的是,在编译模型时可以设置所有损失权重:
optimizer = tf.keras.optimizers.Adam(learning_rate=1e-3)
model.compile(loss=("mse", "mse"), loss_weights=(0.9, 0.1), optimizer=optimizer,
metrics=["RootMeanSquaredError"])
提示
您可以传递一个字典loss={"output": "mse", "aux_output": "mse"},而不是传递一个元组loss=("mse", "mse"),假设您使用name="output"和name="aux_output"创建了输出层。就像对于输入一样,这样可以澄清代码并避免在有多个输出时出现错误。您还可以为loss_weights传递一个字典。
现在当我们训练模型时,我们需要为每个输出提供标签。在这个例子中,主要输出和辅助输出应该尝试预测相同的事物,因此它们应该使用相同的标签。因此,我们需要传递(y_train, y_train),或者如果输出被命名为"output"和"aux_output",则传递一个字典{"output": y_train, "aux_output": y_train},而不是传递y_train。对于y_valid和y_test也是一样的:
norm_layer_wide.adapt(X_train_wide)
norm_layer_deep.adapt(X_train_deep)
history = model.fit(
(X_train_wide, X_train_deep), (y_train, y_train), epochs=20,
validation_data=((X_valid_wide, X_valid_deep), (y_valid, y_valid))
)
当我们评估模型时,Keras 会返回损失的加权和,以及所有单独的损失和指标:
eval_results = model.evaluate((X_test_wide, X_test_deep), (y_test, y_test))
weighted_sum_of_losses, main_loss, aux_loss, main_rmse, aux_rmse = eval_results
提示
如果设置return_dict=True,那么evaluate()将返回一个字典而不是一个大元组。
类似地,predict()方法将为每个输出返回预测:
y_pred_main, y_pred_aux = model.predict((X_new_wide, X_new_deep))
predict()方法返回一个元组,并且没有return_dict参数以获得一个字典。但是,您可以使用model.output_names创建一个:
y_pred_tuple = model.predict((X_new_wide, X_new_deep))
y_pred = dict(zip(model.output_names, y_pred_tuple))
正如您所看到的,您可以使用功能 API 构建各种架构。接下来,我们将看一下您可以构建 Keras 模型的最后一种方法。
使用子类 API 构建动态模型
顺序 API 和功能 API 都是声明式的:您首先声明要使用哪些层以及它们应该如何连接,然后才能开始向模型提供一些数据进行训练或推断。这有许多优点:模型可以很容易地被保存、克隆和共享;其结构可以被显示和分析;框架可以推断形状并检查类型,因此可以在任何数据通过模型之前尽早捕获错误。调试也相当简单,因为整个模型是一组静态图层。但是反过来也是如此:它是静态的。一些模型涉及循环、变化的形状、条件分支和其他动态行为。对于这种情况,或者如果您更喜欢更具有命令式编程风格,子类 API 适合您。
使用这种方法,您可以对Model类进行子类化,在构造函数中创建所需的层,并在call()方法中使用它们执行您想要的计算。例如,创建以下WideAndDeepModel类的实例会给我们一个与我们刚刚使用功能 API 构建的模型等效的模型:
class WideAndDeepModel(tf.keras.Model):
def __init__(self, units=30, activation="relu", **kwargs):
super().__init__(**kwargs) # needed to support naming the model
self.norm_layer_wide = tf.keras.layers.Normalization()
self.norm_layer_deep = tf.keras.layers.Normalization()
self.hidden1 = tf.keras.layers.Dense(units, activation=activation)
self.hidden2 = tf.keras.layers.Dense(units, activation=activation)
self.main_output = tf.keras.layers.Dense(1)
self.aux_output = tf.keras.layers.Dense(1)
def call(self, inputs):
input_wide, input_deep = inputs
norm_wide = self.norm_layer_wide(input_wide)
norm_deep = self.norm_layer_deep(input_deep)
hidden1 = self.hidden1(norm_deep)
hidden2 = self.hidden2(hidden1)
concat = tf.keras.layers.concatenate([norm_wide, hidden2])
output = self.main_output(concat)
aux_output = self.aux_output(hidden2)
return output, aux_output
model = WideAndDeepModel(30, activation="relu", name="my_cool_model")
这个例子看起来与前一个例子相似,只是我们在构造函数中将层的创建与它们在call()方法中的使用分开。而且我们不需要创建Input对象:我们可以在call()方法中使用input参数。
现在我们有了一个模型实例,我们可以对其进行编译,调整其归一化层(例如,使用model.norm_layer_wide.adapt(...)和model.norm_layer_deep.adapt(...)),拟合它,评估它,并使用它进行预测,就像我们使用功能 API 一样。
这个 API 的一个重要区别是,您可以在call()方法中包含几乎任何您想要的东西:for循环,if语句,低级别的 TensorFlow 操作——您的想象力是唯一的限制(参见第十二章)!这使得它成为一个很好的 API,特别适用于研究人员尝试新想法。然而,这种额外的灵活性是有代价的:您的模型架构被隐藏在call()方法中,因此 Keras 无法轻松地检查它;模型无法使用tf.keras.models.clone_model()进行克隆;当您调用summary()方法时,您只会得到一个层列表,而没有关于它们如何连接在一起的任何信息。此外,Keras 无法提前检查类型和形状,容易出错。因此,除非您真的需要额外的灵活性,否则您可能应该坚持使用顺序 API 或功能 API。
提示
Keras 模型可以像常规层一样使用,因此您可以轻松地将它们组合在一起构建复杂的架构。
现在您知道如何使用 Keras 构建和训练神经网络,您会想要保存它们!
保存和恢复模型
保存训练好的 Keras 模型就是这么简单:
model.save("my_keras_model", save_format="tf")
当您设置save_format="tf"时,Keras 会使用 TensorFlow 的SavedModel格式保存模型:这是一个目录(带有给定名称),包含多个文件和子目录。特别是,saved_model.pb文件包含模型的架构和逻辑,以序列化的计算图形式,因此您不需要部署模型的源代码才能在生产中使用它;SavedModel 就足够了(您将在第十二章中看到这是如何工作的)。keras_metadata.pb文件包含 Keras 所需的额外信息。variables子目录包含所有参数值(包括连接权重、偏差、归一化统计数据和优化器参数),如果模型非常大,可能会分成多个文件。最后,assets目录可能包含额外的文件,例如数据样本、特征名称、类名等。默认情况下,assets目录为空。由于优化器也被保存了,包括其超参数和可能存在的任何状态,加载模型后,您可以继续训练。
注意
如果设置save_format="h5"或使用以.h5、.hdf5或.keras结尾的文件名,则 Keras 将使用基于 HDF5 格式的 Keras 特定格式将模型保存到单个文件中。然而,大多数 TensorFlow 部署工具需要使用 SavedModel 格式。
通常会有一个脚本用于训练模型并保存它,以及一个或多个脚本(或 Web 服务)用于加载模型并用于评估或进行预测。加载模型和保存模型一样简单:
model = tf.keras.models.load_model("my_keras_model")
y_pred_main, y_pred_aux = model.predict((X_new_wide, X_new_deep))
您还可以使用save_weights()和load_weights()来仅保存和加载参数值。这包括连接权重、偏差、预处理统计数据、优化器状态等。参数值保存在一个或多个文件中,例如my_weights.data-00004-of-00052,再加上一个索引文件,如my_weights.index。
仅保存权重比保存整个模型更快,占用更少的磁盘空间,因此在训练过程中保存快速检查点非常完美。如果您正在训练一个大模型,需要数小时或数天,那么您必须定期保存检查点以防计算机崩溃。但是如何告诉fit()方法保存检查点呢?使用回调。
使用回调
fit()方法接受一个callbacks参数,让您可以指定一个对象列表,Keras 会在训练之前和之后、每个时代之前和之后,甚至在处理每个批次之前和之后调用它们。例如,ModelCheckpoint回调会在训练期间定期保存模型的检查点,默认情况下在每个时代结束时:
checkpoint_cb = tf.keras.callbacks.ModelCheckpoint("my_checkpoints",
save_weights_only=True)
history = model.fit([...], callbacks=[checkpoint_cb])
此外,在训练过程中使用验证集时,您可以在创建 ModelCheckpoint 时设置 save_best_only=True。在这种情况下,它只会在模型在验证集上的表现迄今为止最好时保存您的模型。这样,您就不需要担心训练时间过长和过拟合训练集:只需在训练后恢复最后保存的模型,这将是验证集上的最佳模型。这是实现提前停止的一种方式(在第四章中介绍),但它实际上不会停止训练。
另一种方法是使用 EarlyStopping 回调。当在一定数量的周期(由 patience 参数定义)内在验证集上测量不到进展时,它将中断训练,如果您设置 restore_best_weights=True,它将在训练结束时回滚到最佳模型。您可以结合这两个回调来保存模型的检查点,以防计算机崩溃,并在没有进展时提前中断训练,以避免浪费时间和资源并减少过拟合:
early_stopping_cb = tf.keras.callbacks.EarlyStopping(patience=10,
restore_best_weights=True)
history = model.fit([...], callbacks=[checkpoint_cb, early_stopping_cb])
由于训练将在没有进展时自动停止(只需确保学习率不要太小,否则可能会一直缓慢进展直到结束),所以可以将周期数设置为一个较大的值。EarlyStopping 回调将在 RAM 中存储最佳模型的权重,并在训练结束时为您恢复它们。
提示
在 tf.keras.callbacks 包中还有许多其他回调可用。
如果您需要额外的控制,您可以轻松编写自己的自定义回调。例如,以下自定义回调将在训练过程中显示验证损失和训练损失之间的比率(例如,用于检测过拟合):
class PrintValTrainRatioCallback(tf.keras.callbacks.Callback):
def on_epoch_end(self, epoch, logs):
ratio = logs["val_loss"] / logs["loss"]
print(f"Epoch={epoch}, val/train={ratio:.2f}")
正如您可能期望的那样,您可以实现 on_train_begin()、on_train_end()、on_epoch_begin()、on_epoch_end()、on_batch_begin() 和 on_batch_end()。回调也可以在评估和预测期间使用,如果您需要的话(例如,用于调试)。对于评估,您应该实现 on_test_begin()、on_test_end()、on_test_batch_begin() 或 on_test_batch_end(),这些方法由 evaluate() 调用。对于预测,您应该实现 on_predict_begin()、on_predict_end()、on_predict_batch_begin() 或 on_predict_batch_end(),这些方法由 predict() 调用。
现在让我们再看看在使用 Keras 时您绝对应该拥有的另一个工具:TensorBoard。
使用 TensorBoard 进行可视化
TensorBoard 是一个很棒的交互式可视化工具,您可以使用它来查看训练过程中的学习曲线,比较多次运行之间的曲线和指标,可视化计算图,分析训练统计数据,查看模型生成的图像,将复杂的多维数据投影到 3D 并自动为您进行聚类,分析您的网络(即,测量其速度以识别瓶颈),等等!
TensorBoard 在安装 TensorFlow 时会自动安装。但是,您需要一个 TensorBoard 插件来可视化分析数据。如果您按照https://homl.info/install上的安装说明在本地运行所有内容,那么您已经安装了插件,但如果您在使用 Colab,则必须运行以下命令:
%pip install -q -U tensorboard-plugin-profile
要使用 TensorBoard,必须修改程序,以便将要可视化的数据输出到称为事件文件的特殊二进制日志文件中。每个二进制数据记录称为摘要。TensorBoard 服务器将监视日志目录,并自动捕捉更改并更新可视化:这使您能够可视化实时数据(有短暂延迟),例如训练期间的学习曲线。通常,您希望将 TensorBoard 服务器指向一个根日志目录,并配置程序,使其在每次运行时写入不同的子目录。这样,同一个 TensorBoard 服务器实例将允许您可视化和比较程序的多次运行中的数据,而不会混淆一切。
让我们将根日志目录命名为my_logs,并定义一个小函数,根据当前日期和时间生成日志子目录的路径,以便在每次运行时都不同:
from pathlib import Path
from time import strftime
def get_run_logdir(root_logdir="my_logs"):
return Path(root_logdir) / strftime("run_%Y_%m_%d_%H_%M_%S")
run_logdir = get_run_logdir() # e.g., my_logs/run_2022_08_01_17_25_59
好消息是,Keras 提供了一个方便的TensorBoard()回调,它会为您创建日志目录(以及必要时的父目录),并在训练过程中创建事件文件并写入摘要。它将测量模型的训练和验证损失和指标(在本例中是 MSE 和 RMSE),还会对神经网络进行分析。使用起来很简单:
tensorboard_cb = tf.keras.callbacks.TensorBoard(run_logdir,
profile_batch=(100, 200))
history = model.fit([...], callbacks=[tensorboard_cb])
就是这样!在这个例子中,它将在第一个时期的 100 和 200 批之间对网络进行分析。为什么是 100 和 200?嗯,神经网络通常需要几批数据来“热身”,所以你不希望太早进行分析,而且分析会使用资源,最好不要为每一批数据都进行分析。
接下来,尝试将学习率从 0.001 更改为 0.002,然后再次运行代码,使用一个新的日志子目录。你将得到一个类似于这样的目录结构:
my_logs
├── run_2022_08_01_17_25_59
│ ├── train
│ │ ├── events.out.tfevents.1659331561.my_host_name.42042.0.v2
│ │ ├── events.out.tfevents.1659331562.my_host_name.profile-empty
│ │ └── plugins
│ │ └── profile
│ │ └── 2022_08_01_17_26_02
│ │ ├── my_host_name.input_pipeline.pb
│ │ └── [...]
│ └── validation
│ └── events.out.tfevents.1659331562.my_host_name.42042.1.v2
└── run_2022_08_01_17_31_12
└── [...]
每次运行都有一个目录,每个目录包含一个用于训练日志和一个用于验证日志的子目录。两者都包含事件文件,而训练日志还包括分析跟踪。
现在你已经准备好事件文件,是时候启动 TensorBoard 服务器了。可以直接在 Jupyter 或 Colab 中使用 TensorBoard 的 Jupyter 扩展来完成,该扩展会随 TensorBoard 库一起安装。这个扩展在 Colab 中是预安装的。以下代码加载了 TensorBoard 的 Jupyter 扩展,第二行启动了一个 TensorBoard 服务器,连接到这个服务器并直接在 Jupyter 中显示用户界面。服务器会监听大于或等于 6006 的第一个可用 TCP 端口(或者您可以使用--port选项设置您想要的端口)。
%load_ext tensorboard
%tensorboard --logdir=./my_logs
提示
如果你在自己的机器上运行所有内容,可以通过在终端中执行tensorboard --logdir=./my_logs来启动 TensorBoard。您必须首先激活安装了 TensorBoard 的 Conda 环境,并转到handson-ml3目录。一旦服务器启动,访问http://localhost:6006。
现在你应该看到 TensorBoard 的用户界面。点击 SCALARS 选项卡查看学习曲线(参见图 10-16)。在左下角,选择要可视化的日志(例如第一次和第二次运行的训练日志),然后点击epoch_loss标量。注意,训练损失在两次运行期间都很好地下降了,但在第二次运行中,由于更高的学习率,下降速度稍快。
![mls3 1016]()
图 10-16。使用 TensorBoard 可视化学习曲线
您还可以在 GRAPHS 选项卡中可视化整个计算图,在 PROJECTOR 选项卡中将学习的权重投影到 3D 中,在 PROFILE 选项卡中查看性能跟踪。TensorBoard()回调还有选项可以记录额外的数据(请参阅文档以获取更多详细信息)。您可以点击右上角的刷新按钮(⟳)使 TensorBoard 刷新数据,也可以点击设置按钮(⚙)激活自动刷新并指定刷新间隔。
此外,TensorFlow 在tf.summary包中提供了一个较低级别的 API。以下代码使用create_file_writer()函数创建一个SummaryWriter,并将此写入器用作 Python 上下文来记录标量、直方图、图像、音频和文本,所有这些都可以使用 TensorBoard 进行可视化:
test_logdir = get_run_logdir()
writer = tf.summary.create_file_writer(str(test_logdir))
with writer.as_default():
for step in range(1, 1000 + 1):
tf.summary.scalar("my_scalar", np.sin(step / 10), step=step)
data = (np.random.randn(100) + 2) * step / 100 # gets larger
tf.summary.histogram("my_hist", data, buckets=50, step=step)
images = np.random.rand(2, 32, 32, 3) * step / 1000 # gets brighter
tf.summary.image("my_images", images, step=step)
texts = ["The step is " + str(step), "Its square is " + str(step ** 2)]
tf.summary.text("my_text", texts, step=step)
sine_wave = tf.math.sin(tf.range(12000) / 48000 * 2 * np.pi * step)
audio = tf.reshape(tf.cast(sine_wave, tf.float32), [1, -1, 1])
tf.summary.audio("my_audio", audio, sample_rate=48000, step=step)
如果您运行此代码并在 TensorBoard 中点击刷新按钮,您将看到几个选项卡出现:IMAGES、AUDIO、DISTRIBUTIONS、HISTOGRAMS 和 TEXT。尝试点击 IMAGES 选项卡,并使用每个图像上方的滑块查看不同时间步的图像。同样,转到 AUDIO 选项卡并尝试在不同时间步听音频。正如您所看到的,TensorBoard 甚至在 TensorFlow 或深度学习之外也是一个有用的工具。
提示
您可以通过将结果发布到https://tensorboard.dev来在线共享您的结果。为此,只需运行!tensorboard dev upload --logdir ./my_logs。第一次运行时,它会要求您接受条款和条件并进行身份验证。然后您的日志将被上传,您将获得一个永久链接,以在 TensorBoard 界面中查看您的结果。
让我们总结一下你在本章学到的内容:你现在知道神经网络的起源,MLP 是什么以及如何将其用于分类和回归,如何使用 Keras 的顺序 API 构建 MLP,以及如何使用功能 API 或子类 API 构建更复杂的模型架构(包括 Wide & Deep 模型,以及具有多个输入和输出的模型)。您还学会了如何保存和恢复模型,以及如何使用回调函数进行检查点、提前停止等。最后,您学会了如何使用 TensorBoard 进行可视化。您已经可以开始使用神经网络来解决许多问题了!但是,您可能想知道如何选择隐藏层的数量、网络中的神经元数量以及所有其他超参数。让我们现在来看看这个问题。
微调神经网络超参数
神经网络的灵活性也是它们的主要缺点之一:有许多超参数需要调整。不仅可以使用任何想象得到的网络架构,甚至在基本的 MLP 中,您可以更改层的数量、每层中要使用的神经元数量和激活函数的类型、权重初始化逻辑、要使用的优化器类型、学习率、批量大小等。您如何知道哪种超参数组合对您的任务最好?
一种选择是将您的 Keras 模型转换为 Scikit-Learn 估计器,然后使用GridSearchCV或RandomizedSearchCV来微调超参数,就像您在第二章中所做的那样。为此,您可以使用 SciKeras 库中的KerasRegressor和KerasClassifier包装类(有关更多详细信息,请参阅https://github.com/adriangb/scikeras)。但是,还有一种更好的方法:您可以使用Keras Tuner库,这是一个用于 Keras 模型的超参数调整库。它提供了几种调整策略,可以高度定制,并且与 TensorBoard 有很好的集成。让我们看看如何使用它。
如果您按照https://homl.info/install中的安装说明在本地运行所有内容,那么您已经安装了 Keras Tuner,但如果您使用 Colab,则需要运行 %pip install -q -U keras-tuner。接下来,导入 keras_tuner,通常为 kt,然后编写一个函数来构建、编译并返回一个 Keras 模型。该函数必须接受一个 kt.HyperParameters 对象作为参数,它可以用来定义超参数(整数、浮点数、字符串等)以及它们可能的取值范围,这些超参数可以用来构建和编译模型。例如,以下函数构建并编译了一个用于分类时尚 MNIST 图像的 MLP,使用超参数如隐藏层的数量(n_hidden)、每层神经元的数量(n_neurons)、学习率(learning_rate)和要使用的优化器类型(optimizer):
import keras_tuner as kt
def build_model(hp):
n_hidden = hp.Int("n_hidden", min_value=0, max_value=8, default=2)
n_neurons = hp.Int("n_neurons", min_value=16, max_value=256)
learning_rate = hp.Float("learning_rate", min_value=1e-4, max_value=1e-2,
sampling="log")
optimizer = hp.Choice("optimizer", values=["sgd", "adam"])
if optimizer == "sgd":
optimizer = tf.keras.optimizers.SGD(learning_rate=learning_rate)
else:
optimizer = tf.keras.optimizers.Adam(learning_rate=learning_rate)
model = tf.keras.Sequential()
model.add(tf.keras.layers.Flatten())
for _ in range(n_hidden):
model.add(tf.keras.layers.Dense(n_neurons, activation="relu"))
model.add(tf.keras.layers.Dense(10, activation="softmax"))
model.compile(loss="sparse_categorical_crossentropy", optimizer=optimizer,
metrics=["accuracy"])
return model
函数的第一部分定义了超参数。例如,hp.Int("n_hidden", min_value=0, max_value=8, default=2) 检查了名为 "n_hidden" 的超参数是否已经存在于 hp 的 HyperParameters 对象中,如果存在,则返回其值。如果不存在,则注册一个新的整数超参数,名为 "n_hidden",其可能的取值范围从 0 到 8(包括边界),并返回默认值,在本例中默认值为 2(当未设置 default 时,返回 min_value)。 "n_neurons" 超参数以类似的方式注册。 "learning_rate" 超参数注册为一个浮点数,范围从 10^(-4) 到 10^(-2),由于 sampling="log",所有尺度的学习率将被等概率采样。最后,optimizer 超参数注册了两个可能的值:"sgd" 或 "adam"(默认值是第一个,本例中为 "sgd")。根据 optimizer 的值,我们创建一个具有给定学习率的 SGD 优化器或 Adam 优化器。
函数的第二部分只是使用超参数值构建模型。它创建一个 Sequential 模型,从一个 Flatten 层开始,然后是请求的隐藏层数量(由 n_hidden 超参数确定)使用 ReLU 激活函数,以及一个具有 10 个神经元(每类一个)的输出层,使用 softmax 激活函数。最后,函数编译模型并返回它。
现在,如果您想进行基本的随机搜索,可以创建一个 kt.RandomSearch 调谐器,将 build_model 函数传递给构造函数,并调用调谐器的 search() 方法:
random_search_tuner = kt.RandomSearch(
build_model, objective="val_accuracy", max_trials=5, overwrite=True,
directory="my_fashion_mnist", project_name="my_rnd_search", seed=42)
random_search_tuner.search(X_train, y_train, epochs=10,
validation_data=(X_valid, y_valid))
RandomSearch 调谐器首先使用一个空的 Hyperparameters 对象调用 build_model() 一次,以收集所有超参数规范。然后,在这个例子中,它运行 5 个试验;对于每个试验,它使用在其各自范围内随机抽样的超参数构建一个模型,然后对该模型进行 10 个周期的训练,并将其保存到 my_fashion_mnist/my_rnd_search 目录的子目录中。由于 overwrite=True,在训练开始之前 my_rnd_search 目录将被删除。如果您再次运行此代码,但使用 overwrite=False 和 max_trials=10,调谐器将继续从上次停止的地方进行调谐,运行 5 个额外的试验:这意味着您不必一次性运行所有试验。最后,由于 objective 设置为 "val_accuracy",调谐器更喜欢具有更高验证准确性的模型,因此一旦调谐器完成搜索,您可以像这样获取最佳模型:
top3_models = random_search_tuner.get_best_models(num_models=3)
best_model = top3_models[0]
您还可以调用 get_best_hyperparameters() 来获取最佳模型的 kt.HyperParameters:
>>> top3_params = random_search_tuner.get_best_hyperparameters(num_trials=3)
>>> top3_params[0].values # best hyperparameter values
{'n_hidden': 5,
'n_neurons': 70,
'learning_rate': 0.00041268008323824807,
'optimizer': 'adam'}
每个调谐器都由一个所谓的oracle指导:在每次试验之前,调谐器会询问 oracle 告诉它下一个试验应该是什么。RandomSearch调谐器使用RandomSearchOracle,它非常基本:就像我们之前看到的那样,它只是随机选择下一个试验。由于 oracle 跟踪所有试验,您可以要求它给出最佳试验,并显示该试验的摘要:
>>> best_trial = random_search_tuner.oracle.get_best_trials(num_trials=1)[0]
>>> best_trial.summary()
Trial summary
Hyperparameters:
n_hidden: 5
n_neurons: 70
learning_rate: 0.00041268008323824807
optimizer: adam
Score: 0.8736000061035156
这显示了最佳超参数(与之前一样),以及验证准确率。您也可以直接访问所有指标:
>>> best_trial.metrics.get_last_value("val_accuracy")
0.8736000061035156
如果您对最佳模型的性能感到满意,您可以在完整的训练集(X_train_full和y_train_full)上继续训练几个时期,然后在测试集上评估它,并将其部署到生产环境(参见第十九章):
best_model.fit(X_train_full, y_train_full, epochs=10)
test_loss, test_accuracy = best_model.evaluate(X_test, y_test)
在某些情况下,您可能希望微调数据预处理超参数或model.fit()参数,比如批量大小。为此,您必须使用略有不同的技术:而不是编写一个build_model()函数,您必须子类化kt.HyperModel类并定义两个方法,build()和fit()。build()方法执行与build_model()函数完全相同的操作。fit()方法接受一个HyperParameters对象和一个已编译的模型作为参数,以及所有model.fit()参数,并拟合模型并返回History对象。关键是,fit()方法可以使用超参数来决定如何预处理数据,调整批量大小等。例如,以下类构建了与之前相同的模型,具有相同的超参数,但它还使用一个布尔型"normalize"超参数来控制是否在拟合模型之前标准化训练数据:
class MyClassificationHyperModel(kt.HyperModel):
def build(self, hp):
return build_model(hp)
def fit(self, hp, model, X, y, **kwargs):
if hp.Boolean("normalize"):
norm_layer = tf.keras.layers.Normalization()
X = norm_layer(X)
return model.fit(X, y, **kwargs)
然后,您可以将此类的实例传递给您选择的调谐器,而不是传递build_model函数。例如,让我们基于MyClassificationHyperModel实例构建一个kt.Hyperband调谐器:
hyperband_tuner = kt.Hyperband(
MyClassificationHyperModel(), objective="val_accuracy", seed=42,
max_epochs=10, factor=3, hyperband_iterations=2,
overwrite=True, directory="my_fashion_mnist", project_name="hyperband")
这个调谐器类似于我们在第二章中讨论的HalvingRandomSearchCV类:它首先为少数时期训练许多不同的模型,然后消除最差的模型,仅保留前1 / factor个模型(在这种情况下是前三分之一),重复此选择过程,直到只剩下一个模型。max_epochs参数控制最佳模型将被训练的最大时期数。在这种情况下,整个过程重复两次(hyperband_iterations=2)。每个超带迭代中所有模型的总训练时期数约为max_epochs * (log(max_epochs) / log(factor)) ** 2,因此在这个例子中大约为 44 个时期。其他参数与kt.RandomSearch相同。
现在让我们运行 Hyperband 调谐器。我们将使用TensorBoard回调,这次指向根日志目录(调谐器将负责为每个试验使用不同的子目录),以及一个EarlyStopping回调:
root_logdir = Path(hyperband_tuner.project_dir) / "tensorboard"
tensorboard_cb = tf.keras.callbacks.TensorBoard(root_logdir)
early_stopping_cb = tf.keras.callbacks.EarlyStopping(patience=2)
hyperband_tuner.search(X_train, y_train, epochs=10,
validation_data=(X_valid, y_valid),
callbacks=[early_stopping_cb, tensorboard_cb])
现在,如果您打开 TensorBoard,将--logdir指向my_fashion_mnist/hyperband/tensorboard目录,您将看到所有试验结果的展示。确保访问 HPARAMS 选项卡:它包含了所有尝试过的超参数组合的摘要,以及相应的指标。请注意,在 HPARAMS 选项卡内部有三个选项卡:表格视图、平行坐标视图和散点图矩阵视图。在左侧面板的下部,取消选中除了validation.epoch_accuracy之外的所有指标:这将使图表更清晰。在平行坐标视图中,尝试选择validation.epoch_accuracy列中的高值范围:这将仅显示达到良好性能的超参数组合。单击其中一个超参数组合,相应的学习曲线将出现在页面底部。花些时间浏览每个选项卡;这将帮助您了解每个超参数对性能的影响,以及超参数之间的相互作用。
Hyperband 比纯随机搜索更聪明,因为它分配资源的方式更为高效,但在其核心部分仍然是随机探索超参数空间;它快速,但粗糙。然而,Keras Tuner 还包括一个kt.BayesianOptimization调谐器:这种算法通过拟合一个称为高斯过程的概率模型逐渐学习哪些超参数空间区域最有前途。这使得它逐渐聚焦于最佳超参数。缺点是该算法有自己的超参数:alpha代表您在试验中期望性能指标中的噪声水平(默认为 10^(–4)),beta指定您希望算法探索而不仅仅利用已知的超参数空间中的良好区域(默认为 2.6)。除此之外,这个调谐器可以像之前的调谐器一样使用:
bayesian_opt_tuner = kt.BayesianOptimization(
MyClassificationHyperModel(), objective="val_accuracy", seed=42,
max_trials=10, alpha=1e-4, beta=2.6,
overwrite=True, directory="my_fashion_mnist", project_name="bayesian_opt")
bayesian_opt_tuner.search([...])
超参数调整仍然是一个活跃的研究领域,许多其他方法正在被探索。例如,查看 DeepMind 出色的2017 年论文,其中作者使用进化算法共同优化了一组模型和它们的超参数。谷歌也采用了进化方法,不仅用于搜索超参数,还用于探索各种模型架构:它为谷歌 Vertex AI 上的 AutoML 服务提供动力(参见第十九章)。术语AutoML指的是任何系统,它负责 ML 工作流的大部分。进化算法甚至已成功用于训练单个神经网络,取代了无处不在的梯度下降!例如,查看 Uber 在2017 年发布的文章,作者介绍了他们的Deep Neuroevolution技术。
尽管有这些令人兴奋的进展和所有这些工具和服务,但仍然有必要了解每个超参数的合理值,以便您可以构建一个快速原型并限制搜索空间。以下部分提供了选择 MLP 中隐藏层和神经元数量以及选择一些主要超参数的良好值的指导方针。
隐藏层的数量
对于许多问题,您可以从一个隐藏层开始并获得合理的结果。具有一个隐藏层的 MLP 在理论上可以建模甚至最复杂的函数,只要它有足够的神经元。但对于复杂问题,深度网络比浅层网络具有更高的参数效率:它们可以使用指数级较少的神经元来建模复杂函数,从而使它们在相同数量的训练数据下达到更好的性能。
要理解为什么,假设您被要求使用绘图软件画一片森林,但是禁止复制和粘贴任何东西。这将需要大量的时间:您必须逐个绘制每棵树,一枝一枝,一叶一叶。如果您可以绘制一片叶子,复制并粘贴它以绘制一根树枝,然后复制并粘贴该树枝以创建一棵树,最后复制并粘贴这棵树以制作一片森林,您将很快完成。现实世界的数据通常以这种分层方式结构化,深度神经网络会自动利用这一事实:较低的隐藏层模拟低级结构(例如各种形状和方向的线段),中间隐藏层将这些低级结构组合起来模拟中级结构(例如正方形、圆形),最高隐藏层和输出层将这些中级结构组合起来模拟高级结构(例如人脸)。
这种分层结构不仅有助于深度神经网络更快地收敛到一个好的解决方案,而且还提高了它们对新数据集的泛化能力。例如,如果您已经训练了一个模型来识别图片中的人脸,现在想要训练一个新的神经网络来识别发型,您可以通过重用第一个网络的较低层来启动训练。而不是随机初始化新神经网络的前几层的权重和偏置,您可以将它们初始化为第一个网络较低层的权重和偏置的值。这样网络就不必从头学习出现在大多数图片中的所有低级结构;它只需要学习更高级的结构(例如发型)。这就是所谓的迁移学习。
总之,对于许多问题,您可以从只有一个或两个隐藏层开始,神经网络就能正常工作。例如,您可以仅使用一个具有几百个神经元的隐藏层在 MNIST 数据集上轻松达到 97% 以上的准确率,使用两个具有相同总神经元数量的隐藏层在大致相同的训练时间内达到 98% 以上的准确率。对于更复杂的问题,您可以增加隐藏层的数量,直到开始过拟合训练集。非常复杂的任务,例如大型图像分类或语音识别,通常需要具有数十层(甚至数百层,但不是全连接的,如您将在第十四章中看到的)的网络,并且需要大量的训练数据。您很少需要从头开始训练这样的网络:更常见的做法是重用执行类似任务的预训练最先进网络的部分。这样训练速度会更快,需要的数据量也会更少(我们将在第十一章中讨论这一点)。
隐藏层中的神经元数量
输入层和输出层的神经元数量取决于您的任务所需的输入和输出类型。例如,MNIST 任务需要 28 × 28 = 784 个输入和 10 个输出神经元。
至于隐藏层,过去常见的做法是将它们大小设计成金字塔形,每一层的神经元数量越来越少——其理由是许多低级特征可以融合成远远较少的高级特征。一个典型的用于 MNIST 的神经网络可能有 3 个隐藏层,第一个有 300 个神经元,第二个有 200 个,第三个有 100 个。然而,这种做法已经被大多数人放弃,因为似乎在大多数情况下,在所有隐藏层中使用相同数量的神经元表现得同样好,甚至更好;此外,只需调整一个超参数,而不是每一层一个。尽管如此,根据数据集的不同,有时将第一个隐藏层设计得比其他隐藏层更大可能会有所帮助。
就像层数一样,您可以尝试逐渐增加神经元的数量,直到网络开始过拟合。或者,您可以尝试构建一个比实际需要的层数和神经元稍多一点的模型,然后使用提前停止和其他正则化技术来防止过度拟合。Google 的科学家 Vincent Vanhoucke 将此称为“伸展裤”方法:不要浪费时间寻找完全符合您尺寸的裤子,只需使用大号伸展裤,它们会缩小到合适的尺寸。通过这种方法,您可以避免可能破坏模型的瓶颈层。实际上,如果一层的神经元太少,它将没有足够的表征能力来保留来自输入的所有有用信息(例如,具有两个神经元的层只能输出 2D 数据,因此如果它以 3D 数据作为输入,一些信息将丢失)。无论网络的其余部分有多大和强大,该信息都将永远无法恢复。
提示
一般来说,增加层数而不是每层的神经元数量会更有效。
学习率、批量大小和其他超参数
隐藏层和神经元的数量并不是您可以在 MLP 中调整的唯一超参数。以下是一些最重要的超参数,以及如何设置它们的提示:
学习率
学习率可以说是最重要的超参数。一般来说,最佳学习率约为最大学习率的一半(即训练算法发散的学习率上限,如我们在第四章中看到的)。找到一个好的学习率的方法是训练模型几百次迭代,从非常低的学习率(例如,10^(-5))开始,逐渐增加到非常大的值(例如,10)。这是通过在每次迭代时将学习率乘以一个常数因子来完成的(例如,通过(10 / 10(-5))(1 / 500)在 500 次迭代中从 10^(-5)增加到 10)。如果将损失作为学习率的函数绘制出来(使用对数刻度的学习率),您应该会看到它一开始下降。但过一段时间,学习率将变得太大,因此损失会迅速上升:最佳学习率将略低于损失开始上升的点(通常比转折点低约 10 倍)。然后,您可以重新初始化您的模型,并使用这个好的学习率进行正常训练。我们将在第十一章中探讨更多学习率优化技术。
优化器
选择比普通的小批量梯度下降更好的优化器(并调整其超参数)也非常重要。我们将在第十一章中研究几种高级优化器。
批量大小
批量大小可能会对模型的性能和训练时间产生重大影响。使用大批量大小的主要好处是硬件加速器如 GPU 可以高效处理它们(参见第十九章),因此训练算法将每秒看到更多实例。因此,许多研究人员和从业者建议使用能够适应 GPU RAM 的最大批量大小。然而,有一个问题:在实践中,大批量大小通常会导致训练不稳定,特别是在训练开始时,由此产生的模型可能不会像使用小批量大小训练的模型那样泛化得好。2018 年 4 月,Yann LeCun 甚至在推特上发表了“朋友们不要让朋友们使用大于 32 的小批量”的言论,引用了 Dominic Masters 和 Carlo Luschi 在2018 年的一篇论文的结论,该论文认为使用小批量(从 2 到 32)更可取,因为小批量在更短的训练时间内产生更好的模型。然而,其他研究结果却指向相反的方向。例如,2017 年,Elad Hoffer 等人的论文和 Priya Goyal 等人的论文显示,可以使用非常大的批量大小(高达 8,192),并结合各种技术,如学习率预热(即从小学习率开始训练,然后逐渐增加,如第十一章中讨论的那样),以获得非常短的训练时间,而不会出现泛化差距。因此,一种策略是尝试使用大批量大小,结合学习率预热,如果训练不稳定或最终性能令人失望,则尝试改用小批量大小。
激活函数
我们在本章前面讨论了如何选择激活函数:一般来说,ReLU 激活函数将是所有隐藏层的一个很好的默认选择,但对于输出层,它真的取决于您的任务。
迭代次数
在大多数情况下,实际上不需要调整训练迭代次数:只需使用早停止即可。
提示
最佳学习率取决于其他超参数,尤其是批量大小,因此如果您修改任何超参数,请确保同时更新学习率。
有关调整神经网络超参数的最佳实践,请查看 Leslie Smith 的优秀2018 年论文。
这结束了我们关于人工神经网络及其在 Keras 中的实现的介绍。在接下来的几章中,我们将讨论训练非常深的网络的技术。我们还将探讨如何使用 TensorFlow 的低级 API 自定义模型,以及如何使用 tf.data API 高效加载和预处理数据。我们将深入研究其他流行的神经网络架构:用于图像处理的卷积神经网络,用于序列数据和文本的循环神经网络和 transformers,用于表示学习的自编码器,以及用于建模和生成数据的生成对抗网络。
练习
-
TensorFlow playground是由 TensorFlow 团队构建的一个方便的神经网络模拟器。在这个练习中,您将只需点击几下就可以训练几个二元分类器,并调整模型的架构和超参数,以便对神经网络的工作原理和超参数的作用有一些直观的认识。花一些时间来探索以下内容:
-
神经网络学习的模式。尝试通过点击运行按钮(左上角)训练默认的神经网络。注意到它如何快速找到分类任务的良好解决方案。第一个隐藏层中的神经元已经学会了简单的模式,而第二个隐藏层中的神经元已经学会了将第一个隐藏层的简单模式组合成更复杂的模式。一般来说,层数越多,模式就越复杂。
-
激活函数。尝试用 ReLU 激活函数替换 tanh 激活函数,并重新训练网络。注意到它找到解决方案的速度更快,但这次边界是线性的。这是由于 ReLU 函数的形状。
-
局部最小值的风险。修改网络架构,只有一个有三个神经元的隐藏层。多次训练它(要重置网络权重,点击播放按钮旁边的重置按钮)。注意到训练时间变化很大,有时甚至会卡在局部最小值上。
-
当神经网络太小时会发生什么。移除一个神经元,只保留两个。注意到神经网络现在无法找到一个好的解决方案,即使你尝试多次。模型参数太少,系统地欠拟合训练集。
-
当神经网络足够大时会发生什么。将神经元数量设置为八,并多次训练网络。注意到现在训练速度一致快速,从不卡住。这突显了神经网络理论中的一个重要发现:大型神经网络很少会卡在局部最小值上,即使卡住了,这些局部最优解通常几乎和全局最优解一样好。然而,它们仍然可能在长时间的高原上卡住。
-
深度网络中梯度消失的风险。选择螺旋数据集(“DATA”下方的右下数据集),并将网络架构更改为每个有八个神经元的四个隐藏层。注意到训练时间更长,经常在高原上卡住很长时间。还要注意到最高层(右侧)的神经元比最低层(左侧)的神经元进化得更快。这个问题被称为梯度消失问题,可以通过更好的权重初始化和其他技术、更好的优化器(如 AdaGrad 或 Adam)或批量归一化(在第十一章中讨论)来缓解。
-
更进一步。花一个小时左右的时间玩弄其他参数,了解它们的作用,建立对神经网络的直观理解。
-
使用原始人工神经元(如图 10-3 中的人工神经元)绘制一个 ANN,计算A ⊕ B(其中 ⊕ 表示异或操作)。提示:A ⊕ B = (A ∧ ¬ B) ∨ (¬ A ∧ B)。
-
通常更倾向于使用逻辑回归分类器而不是经典感知器(即使用感知器训练算法训练的阈值逻辑单元的单层)。如何调整感知器使其等效于逻辑回归分类器?
-
为什么 Sigmoid 激活函数是训练第一个 MLP 的关键因素?
-
列出三种流行的激活函数。你能画出它们吗?
-
假设你有一个 MLP,由一个具有 10 个传递神经元的输入层、一个具有 50 个人工神经元的隐藏层和一个具有 3 个人工神经元的输出层组成。所有人工神经元都使用 ReLU 激活函数。
-
输入矩阵X的形状是什么?
-
隐藏层权重矩阵W[h]和偏置向量b[h]的形状是什么?
-
输出层权重矩阵W[o]和偏置向量b[o]的形状是什么?
-
网络输出矩阵Y的形状是什么?
-
写出计算网络输出矩阵Y的方程,作为X、W[h]、b[h]、W[o]和b[o]的函数。
-
如果你想将电子邮件分类为垃圾邮件或正常邮件,输出层需要多少个神经元?输出层应该使用什么激活函数?如果你想处理 MNIST 数据集,输出层需要多少个神经元,应该使用哪种激活函数?对于让你的网络预测房价,如第二章中所述,需要多少个神经元,应该使用什么激活函数?
-
什么是反向传播,它是如何工作的?反向传播和反向模式自动微分之间有什么区别?
-
在基本的 MLP 中,你可以调整哪些超参数?如果 MLP 过拟合训练数据,你可以如何调整这些超参数来尝试解决问题?
-
在 MNIST 数据集上训练一个深度 MLP(可以使用tf.keras.datasets.mnist.load_data()加载)。看看你是否可以通过手动调整超参数获得超过 98%的准确率。尝试使用本章介绍的方法搜索最佳学习率(即通过指数增长学习率,绘制损失曲线,并找到损失飙升的点)。接下来,尝试使用 Keras Tuner 调整超参数,包括保存检查点、使用早停止,并使用 TensorBoard 绘制学习曲线。
这些练习的解决方案可以在本章笔记本的末尾找到,网址为https://homl.info/colab3。
¹ 你可以通过对生物启发开放,而不害怕创建生物不现实的模型,来获得两全其美,只要它们运行良好。
² Warren S. McCulloch 和 Walter Pitts,“神经活动中固有思想的逻辑演算”,《数学生物学公报》5 卷 4 期(1943 年):115-113。
³ 它们实际上并没有连接,只是非常接近,可以非常快速地交换化学信号。
⁴ Bruce Blaus 绘制的图像(知识共享 3.0)。来源:https://en.wikipedia.org/wiki/Neuron。
⁵ 在机器学习的背景下,“神经网络”一词通常指的是人工神经网络,而不是生物神经网络。
⁶ S. Ramon y Cajal 绘制的皮层层析图(公有领域)。来源:https://en.wikipedia.org/wiki/Cerebral_cortex。
⁷ 请注意,这个解决方案并不唯一:当数据点线性可分时,有无穷多个可以将它们分开的超平面。
⁸ 例如,当输入为(0,1)时,左下神经元计算 0 × 1 + 1 × 1 - 3 / 2 = -1 / 2,为负数,因此输出为 0。右下神经元计算 0 × 1 + 1 × 1 - 1 / 2 = 1 / 2,为正数,因此输出为 1。输出神经元接收前两个神经元的输出作为输入,因此计算 0 × (-1) + 1 × 1 - 1 / 2 = 1 / 2。这是正数,因此输出为 1。
⁹ 在 20 世纪 90 年代,具有两个以上隐藏层的人工神经网络被认为是深度的。如今,常见的是看到具有数十层甚至数百层的人工神经网络,因此“深度”的定义非常模糊。
¹⁰ 大卫·鲁梅尔哈特等人,“通过误差传播学习内部表示”(国防技术信息中心技术报告,1985 年 9 月)。
¹¹ 生物神经元似乎实现了一个大致呈 S 形的激活函数,因此研究人员长时间坚持使用 Sigmoid 函数。但事实证明,在人工神经网络中,ReLU 通常效果更好。这是生物类比可能误导的一个案例。
¹² ONEIROS 项目(开放式神经电子智能机器人操作系统)。Chollet 在 2015 年加入了谷歌,继续领导 Keras 项目。
¹³ PyTorch 的 API 与 Keras 的相似,因此一旦你了解了 Keras,如果你想要的话,切换到 PyTorch 并不困难。PyTorch 在 2018 年的普及程度呈指数增长,这在很大程度上要归功于其简单性和出色的文档,而这些正是 TensorFlow 1.x 当时的主要弱点。然而,TensorFlow 2 和 PyTorch 一样简单,部分原因是它已经将 Keras 作为其官方高级 API,并且开发人员大大简化和清理了其余的 API。文档也已经完全重新组织,现在更容易找到所需的内容。同样,PyTorch 的主要弱点(例如,有限的可移植性和没有计算图分析)在 PyTorch 1.0 中已经得到了很大程度的解决。健康的竞争对每个人都有益。
¹⁴ 您还可以使用 tf.keras.utils.plot_model() 生成模型的图像。
¹⁵ Heng-Tze Cheng 等人,“广泛和深度学习用于推荐系统”,第一届深度学习推荐系统研讨会论文集(2016):7–10。
¹⁶ 短路径也可以用于向神经网络提供手动设计的特征。
¹⁷ Keras 模型有一个 output 属性,所以我们不能将其用作主输出层的名称,这就是为什么我们将其重命名为 main_output。
¹⁸ 目前这是默认设置,但 Keras 团队正在研究一种可能成为未来默认设置的新格式,因此我更喜欢明确设置格式以保证未来兼容。
¹⁹ Hyperband 实际上比连续减半法更复杂;参见 Lisha Li 等人的论文,“Hyperband: 一种新颖的基于贝叶斯的超参数优化方法”,机器学习研究杂志 18(2018 年 4 月):1–52。
²⁰ Max Jaderberg 等人,“神经网络的基于人口的训练”,arXiv 预印本 arXiv:1711.09846(2017)。
²¹ Dominic Masters 和 Carlo Luschi,“重新审视深度神经网络的小批量训练”,arXiv 预印本 arXiv:1804.07612(2018)。
²² Elad Hoffer 等人,“训练时间更长,泛化效果更好:弥合神经网络大批量训练的泛化差距”,第 31 届国际神经信息处理系统会议论文集(2017):1729–1739。
²³ Priya Goyal 等人,“准确、大型小批量 SGD:在 1 小时内训练 ImageNet”,arXiv 预印本 arXiv:1706.02677(2017)。
²⁴ Leslie N. Smith,“神经网络超参数的纪律性方法:第 1 部分—学习率、批量大小、动量和权重衰减”,arXiv 预印本 arXiv:1803.09820(2018)。
²⁵ 在https://homl.info/extra-anns的在线笔记本中还介绍了一些额外的人工神经网络架构。
第十一章:训练深度神经网络
在第十章中,您构建、训练和微调了您的第一个人工神经网络。但它们是浅层网络,只有几个隐藏层。如果您需要解决一个复杂的问题,比如在高分辨率图像中检测数百种对象,您可能需要训练一个更深的人工神经网络,也许有 10 层或更多层,每一层包含数百个神经元,通过数十万个连接相连。训练深度神经网络并不是一件轻松的事情。以下是您可能遇到的一些问题:
-
在训练过程中,当反向传播通过 DNN 向后流动时,您可能会面临梯度变得越来越小或越来越大的问题。这两个问题都会使得较低层非常难以训练。
-
您可能没有足够的训练数据来训练这样一个庞大的网络,或者标记成本太高。
-
训练可能会非常缓慢。
-
一个拥有数百万参数的模型会严重增加过拟合训练集的风险,特别是如果训练实例不足或者太嘈杂。
在本章中,我们将逐个讨论这些问题,并提出解决方法。我们将首先探讨梯度消失和梯度爆炸问题以及它们最流行的解决方案。接下来,我们将看看迁移学习和无监督预训练,这可以帮助您解决复杂任务,即使您只有很少的标记数据。然后,我们将讨论各种优化器,可以极大地加快训练大型模型。最后,我们将介绍一些用于大型神经网络的流行正则化技术。
有了这些工具,您将能够训练非常深的网络。欢迎来到深度学习!
梯度消失/爆炸问题
正如在第十章中讨论的那样,反向传播算法的第二阶段是从输出层到输入层,沿途传播错误梯度。一旦算法计算出网络中每个参数相对于成本函数的梯度,它就会使用这些梯度来更新每个参数,进行梯度下降步骤。
不幸的是,随着算法向下进行到更低的层,梯度通常会变得越来越小。结果是,梯度下降更新几乎不会改变较低层的连接权重,训练永远不会收敛到一个好的解决方案。这被称为梯度消失问题。在某些情况下,相反的情况可能发生:梯度会变得越来越大,直到层的权重更新变得非常大,算法发散。这是梯度爆炸问题,最常出现在递归神经网络中(参见第十五章)。更一般地说,深度神经网络受到不稳定梯度的困扰;不同层可能以非常不同的速度学习。
或者在-r 和+r 之间的均匀分布,r = sqrt(3 / fan_avg)
在他们的论文中,Glorot 和 Bengio 提出了一种显著减轻不稳定梯度问题的方法。他们指出,我们需要信号在两个方向上正确地流动:在前向方向进行预测时,以及在反向方向进行反向传播梯度时。我们不希望信号消失,也不希望它爆炸和饱和。为了使信号正确地流动,作者认为每一层的输出方差应该等于其输入方差,并且在反向方向通过一层之后,梯度在前后具有相等的方差(如果您对数学细节感兴趣,请查看论文)。实际上,除非层具有相等数量的输入和输出(这些数字称为层的fan-in和fan-out),否则不可能保证两者都相等,但 Glorot 和 Bengio 提出了一个在实践中被证明非常有效的良好折衷方案:每层的连接权重必须随机初始化,如方程 11-1 所述,其中fan[avg] = (fan[in] + fan[out]) / 2。这种初始化策略称为Xavier 初始化或Glorot 初始化,以论文的第一作者命名。
观察 Sigmoid 激活函数(参见图 11-1),您会发现当输入变大(负或正)时,函数在 0 或 1 处饱和,导数非常接近 0(即曲线在两个极端处平坦)。因此,当反向传播开始时,几乎没有梯度可以通过网络向后传播,存在的微小梯度会随着反向传播通过顶层逐渐稀释,因此对于较低层几乎没有剩余的梯度。
图 11-1。Sigmoid 激活函数饱和
Glorot 和 He 初始化
这种不幸的行为早在很久以前就被经验性地观察到,这也是深度神经网络在 2000 年代初大多被放弃的原因之一。当训练 DNN 时,梯度不稳定的原因并不清楚,但在 2010 年的一篇论文中,Xavier Glorot 和 Yoshua Bengio 揭示了一些端倪。作者发现了一些嫌疑人,包括当时最流行的 Sigmoid(逻辑)激活函数和权重初始化技术的组合(即均值为 0,标准差为 1 的正态分布)。简而言之,他们表明,使用这种激活函数和初始化方案,每一层的输出方差远大于其输入方差。在网络中前进,每一层的方差在每一层之后都会增加,直到激活函数在顶层饱和。实际上,这种饱和现象被 sigmoid 函数的均值为 0.5 而不是 0 所加剧(双曲正切函数的均值为 0,在深度网络中的表现略好于 sigmoid 函数)。
方程 11-1。Glorot 初始化(使用 Sigmoid 激活函数时)
正态分布,均值为 0,方差为σ² = 1 / fan_avg
如果您在方程式 11-1 中用fan[in]替换fan[avg],您将得到 Yann LeCun 在 1990 年代提出的初始化策略。他称之为LeCun 初始化。Genevieve Orr 和 Klaus-Robert Müller 甚至在他们 1998 年的书Neural Networks: Tricks of the Trade(Springer)中推荐了这种方法。当fan[in] = fan[out]时,LeCun 初始化等同于 Glorot 初始化。研究人员花了十多年的时间才意识到这个技巧有多重要。使用 Glorot 初始化可以显著加快训练速度,这是深度学习成功的实践之一。
一些论文提供了不同激活函数的类似策略。这些策略仅在方差的规模和它们是否使用fan[avg]或fan[in]上有所不同,如表 11-1 所示(对于均匀分布,只需使用)。为 ReLU 激活函数及其变体提出的初始化策略称为He 初始化或Kaiming 初始化,以论文的第一作者命名。对于 SELU,最好使用 Yann LeCun 的初始化方法,最好使用正态分布。我们将很快介绍所有这些激活函数。
表 11-1。每种激活函数的初始化参数
| 初始化 |
激活函数 |
σ²(正态) |
| Glorot |
无,tanh,sigmoid,softmax |
1 / fan[avg] |
| He |
ReLU,Leaky ReLU,ELU,GELU,Swish,Mish |
2 / fan[in] |
| LeCun |
SELU |
1 / fan[in] |
默认情况下,Keras 使用均匀分布的 Glorot 初始化。当您创建一个层时,您可以通过设置kernel_initializer="he_uniform"或kernel_initializer="he_normal"来切换到 He 初始化。
import tensorflow as tf
dense = tf.keras.layers.Dense(50, activation="relu",
kernel_initializer="he_normal")
或者,您可以使用VarianceScaling初始化器获得表 11-1 中列出的任何初始化方法,甚至更多。例如,如果您想要使用均匀分布并基于fan[avg](而不是fan[in])进行 He 初始化,您可以使用以下代码:
he_avg_init = tf.keras.initializers.VarianceScaling(scale=2., mode="fan_avg",
distribution="uniform")
dense = tf.keras.layers.Dense(50, activation="sigmoid",
kernel_initializer=he_avg_init)
更好的激活函数
2010 年 Glorot 和 Bengio 的一篇论文中的一个见解是,不稳定梯度的问题在一定程度上是由于激活函数的选择不当。直到那时,大多数人都认为,如果自然界选择在生物神经元中使用大致为 S 形的激活函数,那么它们一定是一个很好的选择。但事实证明,其他激活函数在深度神经网络中表现得更好,特别是 ReLU 激活函数,主要是因为它对于正值不会饱和,而且计算速度非常快。
不幸的是,ReLU 激活函数并不完美。它存在一个称为dying ReLUs的问题:在训练过程中,一些神经元实际上“死亡”,意味着它们停止输出除 0 以外的任何值。在某些情况下,您可能会发现您网络的一半神经元已经死亡,尤其是如果您使用了较大的学习率。当神经元的权重被微调得使得 ReLU 函数的输入(即神经元输入的加权和加上偏置项)在训练集中的所有实例中都为负时,神经元就会死亡。当这种情况发生时,它只会继续输出零,并且梯度下降不再影响它,因为当其输入为负时,ReLU 函数的梯度为零。
为了解决这个问题,您可能希望使用 ReLU 函数的变体,比如leaky ReLU。
Leaky ReLU
leaky ReLU 激活函数定义为 LeakyReLUα = max(αz, z)(参见图 11-2)。超参数α定义了函数“泄漏”的程度:它是z < 0 时函数的斜率。对于z < 0,具有斜率的 leaky ReLU 永远不会死亡;它们可能会陷入长时间的昏迷,但最终有机会苏醒。Bing Xu 等人在 2015 年的一篇论文比较了几种 ReLU 激活函数的变体,其中一个结论是,泄漏变体总是优于严格的 ReLU 激活函数。事实上,设置α=0.2(一个巨大的泄漏)似乎比α=0.01(一个小泄漏)表现更好。该论文还评估了随机泄漏 ReLU(RReLU),其中α在训练期间在给定范围内随机选择,并在测试期间固定为平均值。RReLU 表现也相当不错,并似乎作为正则化器,减少了过拟合训练集的风险。最后,该论文评估了参数泄漏 ReLU(PReLU),其中α在训练期间被授权学习:它不再是一个超参数,而是一个可以像其他参数一样通过反向传播修改的参数。据报道,PReLU 在大型图像数据集上明显优于 ReLU,但在较小的数据集上存在过拟合训练集的风险。
![mls3 1102]()
图 11-2. Leaky ReLU:类似于 ReLU,但对负值有一个小的斜率
Keras 在tf.keras.layers包中包含了LeakyReLU和PReLU类。就像其他 ReLU 变体一样,您应该使用 He 初始化。例如:
leaky_relu = tf.keras.layers.LeakyReLU(alpha=0.2) # defaults to alpha=0.3
dense = tf.keras.layers.Dense(50, activation=leaky_relu,
kernel_initializer="he_normal")
如果您愿意,您也可以在模型中将LeakyReLU作为一个单独的层来使用;对于训练和预测没有任何影响:
model = tf.keras.models.Sequential([
[...] # more layers
tf.keras.layers.Dense(50, kernel_initializer="he_normal"), # no activation
tf.keras.layers.LeakyReLU(alpha=0.2), # activation as a separate layer
[...] # more layers
])
对于 PReLU,将LeakyReLU替换为PReLU。目前在 Keras 中没有官方实现 RReLU,但您可以相当容易地实现自己的(要了解如何做到这一点,请参见第十二章末尾的练习)。
ReLU、leaky ReLU 和 PReLU 都存在一个问题,即它们不是平滑函数:它们的导数在z=0 处突然变化。正如我们在第四章中讨论 lasso 时看到的那样,这种不连续性会导致梯度下降在最优点周围反弹,并减慢收敛速度。因此,现在我们将看一些 ReLU 激活函数的平滑变体,从 ELU 和 SELU 开始。
ELU 和 SELU
2015 年,Djork-Arné Clevert 等人提出了一篇论文,提出了一种新的激活函数,称为指数线性单元(ELU),在作者的实验中表现优于所有 ReLU 变体:训练时间缩短,神经网络在测试集上表现更好。方程式 11-2 展示了这个激活函数的定义。
方程式 11-2. ELU 激活函数
ELU 激活函数看起来很像 ReLU 函数(参见图 11-3),但有一些主要区别:
-
当z < 0 时,它会取负值,这使得单元的平均输出更接近于 0,并有助于缓解梯度消失问题。超参数α定义了当z是一个较大的负数时 ELU 函数接近的值的相反数。通常设置为 1,但您可以像调整其他超参数一样进行调整。
-
在z < 0 时具有非零梯度,避免了死神经元问题。
-
如果α等于 1,则该函数在任何地方都是平滑的,包括在z = 0 附近,这有助于加快梯度下降的速度,因为它在z = 0 的左右两侧不会反弹太多。
在 Keras 中使用 ELU 就像设置activation="elu"一样简单,与其他 ReLU 变体一样,应该使用 He 初始化。ELU 激活函数的主要缺点是它的计算速度比 ReLU 函数及其变体慢(由于使用了指数函数)。在训练期间更快的收敛速度可能会弥补这种缓慢的计算,但是在测试时,ELU 网络将比 ReLU 网络慢一点。
![mls3 1103]()
图 11-3. ELU 和 SELU 激活函数
不久之后,Günter Klambauer 等人在2017 年的一篇论文中介绍了缩放 ELU(SELU)激活函数:正如其名称所示,它是 ELU 激活函数的缩放变体(大约是 ELU 的 1.05 倍,使用α ≈ 1.67)。作者们表明,如果构建一个仅由一堆稠密层(即 MLP)组成的神经网络,并且所有隐藏层使用 SELU 激活函数,那么网络将自标准化:每一层的输出在训练过程中倾向于保持均值为 0,标准差为 1,从而解决了梯度消失/爆炸的问题。因此,SELU 激活函数可能在 MLP 中胜过其他激活函数,尤其是深层网络。要在 Keras 中使用它,只需设置activation="selu"。然而,自标准化发生的条件有一些(请参阅论文进行数学证明):
-
输入特征必须标准化:均值为 0,标准差为 1。
-
每个隐藏层的权重必须使用 LeCun 正态初始化。在 Keras 中,这意味着设置kernel_initializer="lecun_normal"。
-
只有在普通 MLP 中才能保证自标准化属性。如果尝试在其他架构中使用 SELU,如循环网络(参见第十五章)或具有跳跃连接(即跳过层的连接,例如在 Wide & Deep 网络中),它可能不会胜过 ELU。
-
您不能使用正则化技术,如ℓ[1]或ℓ[2]正则化、最大范数、批量归一化或常规的 dropout(这些将在本章后面讨论)。
这些是重要的限制条件,因此尽管 SELU 有所承诺,但并没有获得很大的关注。此外,另外三种激活函数似乎在大多数任务上表现出色:GELU、Swish 和 Mish。
GELU、Swish 和 Mish
GELU是由 Dan Hendrycks 和 Kevin Gimpel 在2016 年的一篇论文中引入的。再次,您可以将其视为 ReLU 激活函数的平滑变体。其定义在方程 11-3 中给出,其中Φ是标准高斯累积分布函数(CDF):Φ(z)对应于从均值为 0、方差为 1 的正态分布中随机抽取的值低于z的概率。
方程 11-3. GELU 激活函数
如您在图 11-4 中所见,GELU 类似于 ReLU:当其输入z非常负时,它接近 0,当z非常正时,它接近z。然而,到目前为止我们讨论的所有激活函数都是凸函数且单调递增的,而 GELU 激活函数则不是:从左到右,它开始直线上升,然后下降,达到大约-0.17 的低点(接近 z≈-0.75),最后反弹上升并最终向右上方直线前进。这种相当复杂的形状以及它在每个点上都有曲率的事实可能解释了为什么它效果如此好,尤其是对于复杂任务:梯度下降可能更容易拟合复杂模式。在实践中,它通常优于迄今讨论的任何其他激活函数。然而,它的计算成本稍高,提供的性能提升并不总是足以证明额外成本的必要性。尽管如此,可以证明它大致等于zσ(1.702 z),其中σ是 sigmoid 函数:使用这个近似也非常有效,并且计算速度更快。
![mls3 1104]()
图 11-4. GELU、Swish、参数化 Swish 和 Mish 激活函数
GELU 论文还介绍了sigmoid linear unit(SiLU)激活函数,它等于zσ(z),但在作者的测试中被 GELU 表现得更好。有趣的是,Prajit Ramachandran 等人在2017 年的一篇论文中重新发现了 SiLU 函数,通过自动搜索好的激活函数。作者将其命名为Swish,这个名字很受欢迎。在他们的论文中,Swish 表现优于其他所有函数,包括 GELU。Ramachandran 等人后来通过添加额外的超参数β来推广 Swish,用于缩放 sigmoid 函数的输入。推广后的 Swish 函数为 Swishβ = zσ(βz),因此 GELU 大致等于使用β = 1.702 的推广 Swish 函数。您可以像调整其他超参数一样调整β。另外,也可以将β设置为可训练的,让梯度下降来优化它:这样可以使您的模型更加强大,但也会有过拟合数据的风险。
另一个相当相似的激活函数是Mish,它是由 Diganta Misra 在2019 年的一篇论文中引入的。它被定义为 mish(z) = ztanh(softplus(z)),其中 softplus(z) = log(1 + exp(z))。就像 GELU 和 Swish 一样,它是 ReLU 的平滑、非凸、非单调变体,作者再次进行了许多实验,并发现 Mish 通常优于其他激活函数,甚至比 Swish 和 GELU 稍微好一点。图 11-4 展示了 GELU、Swish(默认β = 1 和β = 0.6)、最后是 Mish。如您所见,当z为负时,Mish 几乎完全重叠于 Swish,当z为正时,几乎完全重叠于 GELU。
提示
那么,对于深度神经网络的隐藏层,你应该使用哪种激活函数?对于简单任务,ReLU 仍然是一个很好的默认选择:它通常和更复杂的激活函数一样好,而且计算速度非常快,许多库和硬件加速器提供了 ReLU 特定的优化。然而,对于更复杂的任务,Swish 可能是更好的默认选择,甚至可以尝试带有可学习β参数的参数化 Swish 来处理最复杂的任务。Mish 可能会给出稍微更好的结果,但需要更多的计算。如果你非常关心运行时延迟,那么你可能更喜欢 leaky ReLU,或者对于更复杂的任务,可以使用参数化 leaky ReLU。对于深度 MLP,可以尝试使用 SELU,但一定要遵守之前列出的约束条件。如果你有多余的时间和计算能力,也可以使用交叉验证来评估其他激活函数。
Keras 支持 GELU 和 Swish,只需使用activation="gelu"或activation="swish"。然而,它目前不支持 Mish 或广义 Swish 激活函数(但请参阅第十二章了解如何实现自己的激活函数和层)。
激活函数就介绍到这里!现在,让我们看一种完全不同的解决不稳定梯度问题的方法:批量归一化。
批量归一化
尽管使用 He 初始化与 ReLU(或其任何变体)可以显著减少训练开始时梯度消失/爆炸问题的危险,但并不能保证它们在训练过程中不会再次出现。
在一篇2015 年的论文中,Sergey Ioffe 和 Christian Szegedy 提出了一种称为批量归一化(BN)的技术,解决了这些问题。该技术包括在模型中在每个隐藏层的激活函数之前或之后添加一个操作。这个操作简单地将每个输入零中心化和归一化,然后使用每层两个新的参数向量进行缩放和移位:一个用于缩放,另一个用于移位。换句话说,该操作让模型学习每个层输入的最佳缩放和均值。在许多情况下,如果将 BN 层作为神经网络的第一层,您就不需要标准化训练集。也就是说,不需要StandardScaler或Normalization;BN 层会为您完成(大致上,因为它一次只看一个批次,并且还可以重新缩放和移位每个输入特征)。
为了将输入零中心化和归一化,算法需要估计每个输入的均值和标准差。它通过评估当前小批量输入的均值和标准差来实现这一点(因此称为“批量归一化”)。整个操作在方程式 11-4 中逐步总结。
方程式 11-4. 批量归一化算法
在这个算法中:
-
μ[B] 是在整个小批量B上评估的输入均值向量(它包含每个输入的一个均值)。
-
m[B] 是小批量中实例的数量。
-
σ[B] 是输入标准差的向量,也是在整个小批量上评估的(它包含每个输入的一个标准差)。
-
^((i)) 是实例i的零中心化和归一化输入向量。
-
ε 是一个微小的数字,避免了除以零,并确保梯度不会增长太大(通常为 10^(–5))。这被称为平滑项。
-
γ 是该层的输出比例参数向量(它包含每个输入的一个比例参数)。
-
⊗ 表示逐元素乘法(每个输入都会乘以其对应的输出比例参数)。
-
β 是该层的输出偏移参数向量(它包含每个输入的一个偏移参数)。每个输入都会被其对应的偏移参数偏移。
-
z^((i)) 是 BN 操作的输出。它是输入的重新缩放和偏移版本。
因此,在训练期间,BN 会标准化其输入,然后重新缩放和偏移它们。很好!那么,在测试时呢?嗯,事情并不那么简单。实际上,我们可能需要为单个实例而不是一批实例进行预测:在这种情况下,我们将无法计算每个输入的均值和标准差。此外,即使我们有一批实例,它可能太小,或者实例可能不是独立且同分布的,因此在批次实例上计算统计数据将是不可靠的。一个解决方案可能是等到训练结束,然后通过神经网络运行整个训练集,并计算 BN 层每个输入的均值和标准差。这些“最终”输入均值和标准差可以在进行预测时代替批次输入均值和标准差。然而,大多数批次归一化的实现在训练期间通过使用该层输入均值和标准差的指数移动平均值来估计这些最终统计数据。这就是当您使用BatchNormalization层时 Keras 自动执行的操作。总之,在每个批次归一化的层中学习了四个参数向量:γ(输出缩放向量)和β(输出偏移向量)通过常规反向传播学习,而μ(最终输入均值向量)和σ(最终输入标准差向量)则使用指数移动平均值进行估计。请注意,μ和σ是在训练期间估计的,但仅在训练后使用(以替换公式 11-4 中的批次输入均值和标准差)。
Ioffe 和 Szegedy 证明了批次归一化显著改善了他们进行实验的所有深度神经网络,从而在 ImageNet 分类任务中取得了巨大的改进(ImageNet 是一个大型图像数据库,被分类为许多类别,通常用于评估计算机视觉系统)。梯度消失问题得到了很大程度的减轻,以至于他们可以使用饱和激活函数,如 tanh 甚至 sigmoid 激活函数。网络对权重初始化也不那么敏感。作者能够使用更大的学习率,显著加快学习过程。具体来说,他们指出:
应用于最先进的图像分类模型,批次归一化在 14 倍更少的训练步骤下实现了相同的准确性,并且以显著的优势击败了原始模型。[...] 使用一组批次归一化的网络,我们在 ImageNet 分类上取得了最佳发布结果:达到 4.9%的前 5 验证错误率(和 4.8%的测试错误率),超过了人类评分者的准确性。
最后,就像一份源源不断的礼物,批次归一化就像一个正则化器,减少了对其他正则化技术(如本章后面描述的 dropout)的需求。
然而,批量归一化确实给模型增加了一些复杂性(尽管它可以消除对输入数据进行归一化的需要,如前面讨论的)。此外,还存在运行时惩罚:由于每一层需要额外的计算,神经网络的预测速度变慢。幸运的是,通常可以在训练后将 BN 层与前一层融合在一起,从而避免运行时惩罚。这是通过更新前一层的权重和偏置,使其直接产生适当规模和偏移的输出来实现的。例如,如果前一层计算XW + b,那么 BN 层将计算γ ⊗ (XW + b - μ) / σ + β(忽略分母中的平滑项ε)。如果我们定义W′ = γ⊗W / σ和b′ = γ ⊗ (b - μ) / σ + β,则方程简化为XW′ + b′。因此,如果我们用更新后的权重和偏置(W′和b′)替换前一层的权重和偏置(W和b),我们可以摆脱 BN 层(TFLite 的转换器会自动执行此操作;请参阅第十九章)。
注意
您可能会发现训练速度相当慢,因为使用批量归一化时,每个时期需要更多的时间。通常,这通常会被 BN 的收敛速度更快所抵消,因此需要更少的时期才能达到相同的性能。总的来说,墙上的时间通常会更短(这是您墙上时钟上测量的时间)。
使用 Keras 实现批量归一化
与 Keras 的大多数事物一样,实现批量归一化是简单直观的。只需在每个隐藏层的激活函数之前或之后添加一个BatchNormalization层。您还可以将 BN 层添加为模型中的第一层,但通常在此位置使用普通的Normalization层效果一样好(它的唯一缺点是您必须首先调用其adapt()方法)。例如,这个模型在每个隐藏层后应用 BN,并将其作为模型中的第一层(在展平输入图像之后):
model = tf.keras.Sequential([
tf.keras.layers.Flatten(input_shape=[28, 28]),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Dense(300, activation="relu",
kernel_initializer="he_normal"),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Dense(100, activation="relu",
kernel_initializer="he_normal"),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Dense(10, activation="softmax")
])
就这样!在这个只有两个隐藏层的微小示例中,批量归一化不太可能产生很大的影响,但对于更深的网络,它可能产生巨大的差异。
让我们显示模型摘要:
>>> model.summary()
Model: "sequential"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
flatten (Flatten) (None, 784) 0
_________________________________________________________________
batch_normalization (BatchNo (None, 784) 3136
_________________________________________________________________
dense (Dense) (None, 300) 235500
_________________________________________________________________
batch_normalization_1 (Batch (None, 300) 1200
_________________________________________________________________
dense_1 (Dense) (None, 100) 30100
_________________________________________________________________
batch_normalization_2 (Batch (None, 100) 400
_________________________________________________________________
dense_2 (Dense) (None, 10) 1010
=================================================================
Total params: 271,346
Trainable params: 268,978
Non-trainable params: 2,368
_________________________________________________________________
正如您所看到的,每个 BN 层都会为每个输入添加四个参数:γ、β、μ和σ(例如,第一个 BN 层会添加 3,136 个参数,即 4×784)。最后两个参数,μ和σ,是移动平均值;它们不受反向传播的影响,因此 Keras 将它们称为“不可训练”¹³(如果您计算 BN 参数的总数,3,136 + 1,200 + 400,然后除以 2,您将得到 2,368,这是该模型中不可训练参数的总数)。
让我们看看第一个 BN 层的参数。其中两个是可训练的(通过反向传播),另外两个不是:
>>> [(var.name, var.trainable) for var in model.layers[1].variables]
[('batch_normalization/gamma:0', True),
('batch_normalization/beta:0', True),
('batch_normalization/moving_mean:0', False),
('batch_normalization/moving_variance:0', False)]
BN 论文的作者主张在激活函数之前而不是之后添加 BN 层(就像我们刚刚做的那样)。关于这一点存在一些争论,因为哪种方式更可取似乎取决于任务-您也可以尝试这个来看看哪个选项在您的数据集上效果最好。要在激活函数之前添加 BN 层,您必须从隐藏层中删除激活函数,并在 BN 层之后作为单独的层添加它们。此外,由于批量归一化层包含每个输入的一个偏移参数,您可以在创建时通过传递use_bias=False来删除前一层的偏置项。最后,通常可以删除第一个 BN 层,以避免将第一个隐藏层夹在两个 BN 层之间。更新后的代码如下:
model = tf.keras.Sequential([
tf.keras.layers.Flatten(input_shape=[28, 28]),
tf.keras.layers.Dense(300, kernel_initializer="he_normal", use_bias=False),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Activation("relu"),
tf.keras.layers.Dense(100, kernel_initializer="he_normal", use_bias=False),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Activation("relu"),
tf.keras.layers.Dense(10, activation="softmax")
])
BatchNormalization类有很多可以调整的超参数。默认值通常是可以的,但偶尔您可能需要调整momentum。当BatchNormalization层更新指数移动平均值时,该超参数将被使用;给定一个新值v(即,在当前批次上计算的新的输入均值或标准差向量),该层使用以下方程更新运行平均值:
一个良好的动量值通常接近于 1;例如,0.9,0.99 或 0.999。对于更大的数据集和更小的小批量,您希望有更多的 9。
另一个重要的超参数是axis:它确定应该对哪个轴进行归一化。默认为-1,这意味着默认情况下将归一化最后一个轴(使用在其他轴上计算的均值和标准差)。当输入批次为 2D(即,批次形状为[批次大小,特征])时,这意味着每个输入特征将基于在批次中所有实例上计算的均值和标准差进行归一化。例如,前面代码示例中的第一个 BN 层将独立地归一化(和重新缩放和移位)784 个输入特征中的每一个。如果我们将第一个 BN 层移到Flatten层之前,那么输入批次将是 3D,形状为[批次大小,高度,宽度];因此,BN 层将计算 28 个均值和 28 个标准差(每个像素列一个,跨批次中的所有实例和列中的所有行计算),并且将使用相同的均值和标准差归一化给定列中的所有像素。还将有 28 个比例参数和 28 个移位参数。如果您仍希望独立处理 784 个像素中的每一个,则应将axis=[1, 2]。
批量归一化已经成为深度神经网络中最常用的层之一,特别是在深度卷积神经网络中讨论的(第十四章),以至于在架构图中通常被省略:假定在每一层之后都添加了 BN。现在让我们看看最后一种稳定梯度的技术:梯度裁剪。
梯度裁剪
另一种缓解梯度爆炸问题的技术是在反向传播过程中裁剪梯度,使其永远不超过某个阈值。这被称为梯度裁剪。¹⁴ 这种技术通常用于循环神经网络中,其中使用批量归一化是棘手的(正如您将在第十五章中看到的)。
在 Keras 中,实现梯度裁剪只需要在创建优化器时设置clipvalue或clipnorm参数,就像这样:
optimizer = tf.keras.optimizers.SGD(clipvalue=1.0)
model.compile([...], optimizer=optimizer)
这个优化器将梯度向量的每个分量剪切到-1.0 和 1.0 之间的值。这意味着损失的所有偏导数(对每个可训练参数)将在-1.0 和 1.0 之间被剪切。阈值是您可以调整的超参数。请注意,这可能会改变梯度向量的方向。例如,如果原始梯度向量是[0.9, 100.0],它主要指向第二轴的方向;但是一旦您按值剪切它,您会得到[0.9, 1.0],它大致指向两个轴之间的对角线。在实践中,这种方法效果很好。如果您希望确保梯度剪切不改变梯度向量的方向,您应该通过设置clipnorm而不是clipvalue来按范数剪切。如果其ℓ[2]范数大于您选择的阈值,则会剪切整个梯度。例如,如果设置clipnorm=1.0,那么向量[0.9, 100.0]将被剪切为[0.00899964, 0.9999595],保持其方向但几乎消除第一个分量。如果您观察到梯度在训练过程中爆炸(您可以使用 TensorBoard 跟踪梯度的大小),您可能希望尝试按值剪切或按范数剪切,使用不同的阈值,看看哪个选项在验证集上表现最好。
重用预训练层
通常不建议从头开始训练一个非常大的 DNN,而不是先尝试找到一个现有的神经网络,完成与您尝试解决的任务类似的任务(我将在第十四章中讨论如何找到它们)。如果找到这样的神经网络,那么通常可以重用大部分层,除了顶部的层。这种技术称为迁移学习。它不仅会显著加快训练速度,而且需要的训练数据明显较少。
假设您可以访问一个经过训练的 DNN,用于将图片分类为 100 个不同的类别,包括动物、植物、车辆和日常物品,现在您想要训练一个 DNN 来分类特定类型的车辆。这些任务非常相似,甚至部分重叠,因此您应该尝试重用第一个网络的部分(参见图 11-5)。
注意
如果您新任务的输入图片与原始任务中使用的图片大小不同,通常需要添加一个预处理步骤,将它们调整为原始模型期望的大小。更一般地说,当输入具有相似的低级特征时,迁移学习效果最好。
![mls3 1105]()
图 11-5。重用预训练层
通常应该替换原始模型的输出层,因为它很可能对新任务没有用处,而且可能不会有正确数量的输出。
同样,原始模型的上层隐藏层不太可能像下层那样有用,因为对于新任务最有用的高级特征可能与对原始任务最有用的特征有很大不同。您需要找到要重用的正确层数。
提示
任务越相似,您将希望重用的层次就越多(从较低层次开始)。对于非常相似的任务,尝试保留所有隐藏层,只替换输出层。
首先尝试冻结所有重用的层(即使它们的权重不可训练,以便梯度下降不会修改它们并保持固定),然后训练您的模型并查看其表现。然后尝试解冻顶部一两个隐藏层,让反向传播调整它们,看看性能是否提高。您拥有的训练数据越多,您可以解冻的层次就越多。解冻重用层时降低学习率也很有用:这将避免破坏它们微调的权重。
如果您仍然无法获得良好的性能,并且训练数据很少,尝试删除顶部隐藏层并再次冻结所有剩余的隐藏层。您可以迭代直到找到要重用的正确层数。如果您有大量训练数据,您可以尝试替换顶部隐藏层而不是删除它们,甚至添加更多隐藏层。
使用 Keras 进行迁移学习
让我们看一个例子。假设时尚 MNIST 数据集仅包含八个类别,例如除凉鞋和衬衫之外的所有类别。有人在该数据集上构建并训练了一个 Keras 模型,并获得了相当不错的性能(>90%的准确率)。我们将这个模型称为 A。现在您想要解决一个不同的任务:您有 T 恤和套头衫的图像,并且想要训练一个二元分类器:对于 T 恤(和上衣)为正,对于凉鞋为负。您的数据集非常小;您只有 200 张带标签的图像。当您为这个任务训练一个新模型(我们称之为模型 B),其架构与模型 A 相同时,您获得了 91.85%的测试准确率。在喝早晨咖啡时,您意识到您的任务与任务 A 非常相似,因此也许迁移学习可以帮助?让我们找出来!
首先,您需要加载模型 A 并基于该模型的层创建一个新模型。您决定重用除输出层以外的所有层:
[...] # Assuming model A was already trained and saved to "my_model_A"
model_A = tf.keras.models.load_model("my_model_A")
model_B_on_A = tf.keras.Sequential(model_A.layers[:-1])
model_B_on_A.add(tf.keras.layers.Dense(1, activation="sigmoid"))
请注意,model_A和model_B_on_A现在共享一些层。当您训练model_B_on_A时,它也会影响model_A。如果您想避免这种情况,您需要在重用其层之前克隆model_A。为此,您可以使用clone_model()克隆模型 A 的架构,然后复制其权重:
model_A_clone = tf.keras.models.clone_model(model_A)
model_A_clone.set_weights(model_A.get_weights())
警告
tf.keras.models.clone_model()仅克隆架构,而不是权重。如果您不使用set_weights()手动复制它们,那么当首次使用克隆模型时,它们将被随机初始化。
现在您可以为任务 B 训练model_B_on_A,但由于新的输出层是随机初始化的,它将产生大误差(至少在最初的几个时期),因此会产生大误差梯度,可能会破坏重用的权重。为了避免这种情况,一种方法是在最初的几个时期内冻结重用的层,让新层有时间学习合理的权重。为此,将每个层的trainable属性设置为False并编译模型:
for layer in model_B_on_A.layers[:-1]:
layer.trainable = False
optimizer = tf.keras.optimizers.SGD(learning_rate=0.001)
model_B_on_A.compile(loss="binary_crossentropy", optimizer=optimizer,
metrics=["accuracy"])
注意
在冻结或解冻层之后,您必须始终编译您的模型。
现在您可以为模型训练几个时期,然后解冻重用的层(这需要重新编译模型)并继续训练以微调任务 B 的重用层。在解冻重用的层之后,通常最好降低学习率,再次避免损坏重用的权重。
history = model_B_on_A.fit(X_train_B, y_train_B, epochs=4,
validation_data=(X_valid_B, y_valid_B))
for layer in model_B_on_A.layers[:-1]:
layer.trainable = True
optimizer = tf.keras.optimizers.SGD(learning_rate=0.001)
model_B_on_A.compile(loss="binary_crossentropy", optimizer=optimizer,
metrics=["accuracy"])
history = model_B_on_A.fit(X_train_B, y_train_B, epochs=16,
validation_data=(X_valid_B, y_valid_B))
那么,最终的结论是什么?好吧,这个模型的测试准确率为 93.85%,比 91.85%高出两个百分点!这意味着迁移学习将错误率减少了近 25%:
>>> model_B_on_A.evaluate(X_test_B, y_test_B)
[0.2546142041683197, 0.9384999871253967]
您相信了吗?您不应该相信:我作弊了!我尝试了许多配置,直到找到一个表现出强烈改进的配置。如果您尝试更改类别或随机种子,您会发现改进通常会下降,甚至消失或反转。我所做的被称为“折磨数据直到它招认”。当一篇论文看起来过于积极时,您应该持怀疑态度:也许这种花哨的新技术实际上并没有太大帮助(事实上,它甚至可能降低性能),但作者尝试了许多变体并仅报告了最佳结果(这可能仅仅是纯粹的运气),而没有提及他们在过程中遇到了多少失败。大多数情况下,这并不是恶意的,但这是科学中许多结果永远无法重现的原因之一。
为什么我作弊了?事实证明,迁移学习在小型密集网络上效果不佳,可能是因为小型网络学习的模式较少,而密集网络学习的是非常具体的模式,这些模式不太可能在其他任务中有用。迁移学习最适用于深度卷积神经网络,这些网络倾向于学习更通用的特征检测器(特别是在较低层)。我们将在第十四章中重新讨论迁移学习,使用我们刚讨论的技术(这次不会作弊,我保证!)。
无监督预训练
假设您想要解决一个复杂的任务,但您没有太多标记的训练数据,而不幸的是,您找不到一个类似任务训练的模型。不要失去希望!首先,您应该尝试收集更多标记的训练数据,但如果您无法做到,您仍然可以执行无监督预训练(见图 11-6)。事实上,收集未标记的训练示例通常很便宜,但标记它们却很昂贵。如果您可以收集大量未标记的训练数据,您可以尝试使用它们来训练一个无监督模型,例如自编码器或生成对抗网络(GAN;见第十七章)。然后,您可以重复使用自编码器的较低层或 GAN 的鉴别器的较低层,添加顶部的输出层,然后使用监督学习(即使用标记的训练示例)微调最终网络。
正是这种技术在 2006 年由 Geoffrey Hinton 及其团队使用,导致了神经网络的复兴和深度学习的成功。直到 2010 年,无监督预训练(通常使用受限玻尔兹曼机(RBMs;请参阅https://homl.info/extra-anns中的笔记本))是深度网络的标准,只有在消失梯度问题得到缓解后,纯粹使用监督学习训练 DNN 才变得更加普遍。无监督预训练(今天通常使用自编码器或 GAN,而不是 RBMs)仍然是一个很好的选择,当您有一个复杂的任务需要解决,没有类似的可重用模型,但有大量未标记的训练数据时。
请注意,在深度学习的早期阶段,训练深度模型是困难的,因此人们会使用一种称为贪婪逐层预训练的技术(在图 11-6 中描述)。他们首先使用单层训练一个无监督模型,通常是一个 RBM,然后冻结该层并在其顶部添加另一层,然后再次训练模型(实际上只是训练新层),然后冻结新层并在其顶部添加另一层,再次训练模型,依此类推。如今,事情简单得多:人们通常一次性训练完整的无监督模型,并使用自编码器或 GAN,而不是 RBMs。
![mls3 1106]()
图 11-6。在无监督训练中,模型使用无监督学习技术在所有数据上进行训练,包括未标记的数据,然后使用监督学习技术仅在标记的数据上对最终任务进行微调;无监督部分可以像这里所示一次训练一层,也可以直接训练整个模型
辅助任务上的预训练
如果您没有太多标记的训练数据,最后一个选择是在一个辅助任务上训练第一个神经网络,您可以轻松获取或生成标记的训练数据,然后重复使用该网络的较低层来执行实际任务。第一个神经网络的较低层将学习特征检测器,很可能可以被第二个神经网络重复使用。
例如,如果您想构建一个识别人脸的系统,您可能只有每个个体的少量图片,显然不足以训练一个良好的分类器。收集每个人数百张照片是不现实的。但是,您可以在网络上收集大量随机人的照片,并训练第一个神经网络来检测两张不同图片是否展示了同一个人。这样的网络将学习良好的人脸特征检测器,因此重用其较低层将允许您训练一个使用很少训练数据的良好人脸分类器。
对于自然语言处理(NLP)应用,您可以下载数百万个文本文档的语料库,并从中自动生成标记数据。例如,您可以随机屏蔽一些单词并训练模型来预测缺失的单词是什么(例如,它应该预测句子“What ___ you saying?”中缺失的单词可能是“are”或“were”)。如果您可以训练模型在这个任务上达到良好的性能,那么它将已经对语言有相当多的了解,您肯定可以在实际任务中重复使用它,并在标记数据上进行微调(我们将在第十五章中讨论更多的预训练任务)。
注意
自监督学习是指从数据本身自动生成标签,例如文本屏蔽示例,然后使用监督学习技术在生成的“标记”数据集上训练模型。
更快的优化器
训练一个非常庞大的深度神经网络可能会非常缓慢。到目前为止,我们已经看到了四种加速训练(并达到更好解决方案)的方法:应用良好的连接权重初始化策略,使用良好的激活函数,使用批量归一化,并重用预训练网络的部分(可能是为辅助任务构建的或使用无监督学习)。另一个巨大的加速来自使用比常规梯度下降优化器更快的优化器。在本节中,我们将介绍最流行的优化算法:动量、Nesterov 加速梯度、AdaGrad、RMSProp,最后是 Adam 及其变体。
动量
想象一颗保龄球在光滑表面上缓坡滚动:它会从慢慢开始,但很快会积累动量,直到最终达到终端速度(如果有一些摩擦或空气阻力)。这就是动量优化的核心思想,由鲍里斯·波利亚克在 1964 年提出。与此相反,常规梯度下降在坡度平缓时会采取小步骤,在坡度陡峭时会采取大步骤,但它永远不会加速。因此,与动量优化相比,常规梯度下降通常要慢得多才能达到最小值。
请记住,梯度下降通过直接减去成本函数J(θ)相对于权重的梯度(∇[θ]J(θ))乘以学习率η来更新权重θ。方程式为θ ← θ - η∇[θ]J(θ)。它不关心先前的梯度是什么。如果局部梯度很小,它会走得很慢。
动量优化非常关注先前梯度是什么:在每次迭代中,它从动量向量 m(乘以学习率η)中减去局部梯度,然后通过添加这个动量向量来更新权重(参见方程 11-5)。换句话说,梯度被用作加速度,而不是速度。为了模拟某种摩擦机制并防止动量增长过大,该算法引入了一个新的超参数β,称为动量,必须设置在 0(高摩擦)和 1(无摩擦)之间。典型的动量值为 0.9。
方程 11-5. 动量算法
您可以验证,如果梯度保持不变,则终端速度(即权重更新的最大大小)等于该梯度乘以学习率η乘以 1 / (1 - β)(忽略符号)。例如,如果β = 0.9,则终端速度等于梯度乘以学习率的 10 倍,因此动量优化的速度比梯度下降快 10 倍!这使得动量优化比梯度下降更快地摆脱高原。我们在第四章中看到,当输入具有非常不同的比例时,成本函数看起来像一个拉长的碗(参见图 4-7)。梯度下降很快下降陡峭的斜坡,但然后需要很长时间才能下降到山谷。相比之下,动量优化将会越来越快地滚动到山谷,直到达到底部(最优解)。在不使用批量归一化的深度神经网络中,上层通常会出现具有非常不同比例的输入,因此使用动量优化会有很大帮助。它还可以帮助跳过局部最优解。
注意
由于动量的原因,优化器可能会稍微超调,然后返回,再次超调,并在稳定在最小值之前多次振荡。这是有摩擦力的好处之一:它消除了这些振荡,从而加快了收敛速度。
在 Keras 中实现动量优化是一件轻而易举的事情:只需使用SGD优化器并设置其momentum超参数,然后躺下来赚钱!
optimizer = tf.keras.optimizers.SGD(learning_rate=0.001, momentum=0.9)
动量优化的一个缺点是它增加了另一个需要调整的超参数。然而,在实践中,动量值 0.9 通常效果很好,几乎总是比常规梯度下降更快。
Nesterov 加速梯度
动量优化的一个小变体,由Yurii Nesterov 于 1983 年提出,¹⁶几乎总是比常规动量优化更快。Nesterov 加速梯度(NAG)方法,也称为Nesterov 动量优化,测量成本函数的梯度不是在本地位置θ处,而是稍微向前在动量方向,即θ + βm(参见方程 11-6)。
第 11-6 方程。Nesterov 加速梯度算法
这个小调整有效是因为通常动量向量将指向正确的方向(即朝向最优解),因此使用稍微更准确的梯度测量更有利于使用稍微更远处的梯度,而不是原始位置处的梯度,如您在图 11-7 中所见(其中∇[1]表示在起始点θ处测量的成本函数的梯度,而∇[2]表示在位于θ + βm的点处测量的梯度)。
![mls3 1107]()
图 11-7。常规与 Nesterov 动量优化:前者应用动量步骤之前计算的梯度,而后者应用动量步骤之后计算的梯度
如您所见,Nesterov 更新最终更接近最优解。随着时间的推移,这些小的改进累积起来,NAG 最终比常规动量优化快得多。此外,请注意,当动量将权重推过山谷时,∇[1]继续推动更远,而∇[2]则向山谷底部推回。这有助于减少振荡,因此 NAG 收敛更快。
要使用 NAG,只需在创建SGD优化器时设置nesterov=True:
optimizer = tf.keras.optimizers.SGD(learning_rate=0.001, momentum=0.9,
nesterov=True)
AdaGrad
考虑再次延长碗问题:梯度下降首先快速沿着最陡的斜坡下降,这并不直指全局最优解,然后它非常缓慢地下降到山谷底部。如果算法能够更早地纠正方向,使其更多地指向全局最优解,那将是很好的。AdaGrad算法通过沿着最陡的维度缩小梯度向量来实现这种校正(参见方程 11-7)。
方程 11-7。AdaGrad 算法
第一步将梯度的平方累积到向量s中(请记住,⊗符号表示逐元素乘法)。这种向量化形式等同于计算s[i] ← s[i] + (∂J(θ)/∂θ[i])²,对于向量s的每个元素s[i]来说,换句话说,每个s[i]累积了成本函数对参数θ[i]的偏导数的平方。如果成本函数沿第i维陡峭,那么在每次迭代中s[i]将变得越来越大。
第二步几乎与梯度下降完全相同,但有一个重大区别:梯度向量被一个因子缩小(⊘符号表示逐元素除法,ε是一个平滑项,用于避免除以零,通常设置为 10^(–10))。这个向量化形式等价于同时计算所有参数θ[i]的。
简而言之,这个算法会衰减学习率,但对于陡峭的维度比对于坡度较缓的维度衰减得更快。这被称为自适应学习率。它有助于更直接地指向全局最优(参见图 11-8)。另一个好处是它需要更少的调整学习率超参数η。
![mls3 1108]()
图 11-8. AdaGrad 与梯度下降的比较:前者可以更早地纠正方向指向最优点
在简单的二次问题上,AdaGrad 通常表现良好,但在训练神经网络时经常会过早停止:学习率被缩小得太多,以至于算法最终在达到全局最优之前完全停止。因此,即使 Keras 有一个Adagrad优化器,你也不应该用它来训练深度神经网络(尽管对于简单任务如线性回归可能是有效的)。不过,理解 AdaGrad 有助于理解其他自适应学习率优化器。
RMSProp
正如我们所见,AdaGrad 有减速得太快并且永远无法收敛到全局最优的风险。RMSProp算法¹⁸通过仅累积最近迭代的梯度来修复这个问题,而不是自训练开始以来的所有梯度。它通过在第一步中使用指数衰减来实现这一点(参见方程 11-8)。
方程 11-8. RMSProp 算法
衰减率ρ通常设置为 0.9。¹⁹ 是的,这又是一个新的超参数,但这个默认值通常效果很好,所以你可能根本不需要调整它。
正如你所期望的,Keras 有一个RMSprop优化器:
optimizer = tf.keras.optimizers.RMSprop(learning_rate=0.001, rho=0.9)
除了在非常简单的问题上,这个优化器几乎总是比 AdaGrad 表现得更好。事实上,直到 Adam 优化算法出现之前,它一直是许多研究人员首选的优化算法。
亚当
Adam,代表自适应矩估计,结合了动量优化和 RMSProp 的思想:就像动量优化一样,它跟踪过去梯度的指数衰减平均值;就像 RMSProp 一样,它跟踪过去梯度的平方的指数衰减平均值(见 Equation 11-9)。这些是梯度的均值和(未居中)方差的估计。均值通常称为第一时刻,而方差通常称为第二时刻,因此算法的名称。
方程 11-9. Adam 算法
在这个方程中,t代表迭代次数(从 1 开始)。
如果只看步骤 1、2 和 5,你会注意到 Adam 与动量优化和 RMSProp 的相似之处:β[1]对应于动量优化中的β,β[2]对应于 RMSProp 中的ρ。唯一的区别是步骤 1 计算的是指数衰减平均值而不是指数衰减和,但实际上这些是等价的,除了一个常数因子(衰减平均值只是衰减和的 1 - β[1]倍)。步骤 3 和 4 有点技术细节:由于m和s初始化为 0,在训练开始时它们会偏向于 0,因此这两个步骤将有助于在训练开始时提升m和s。
动量衰减超参数β[1]通常初始化为 0.9,而缩放衰减超参数β[2]通常初始化为 0.999。与之前一样,平滑项ε通常初始化为一个非常小的数字,如 10^(–7)。这些是Adam类的默认值。以下是如何在 Keras 中创建 Adam 优化器的方法:
optimizer = tf.keras.optimizers.Adam(learning_rate=0.001, beta_1=0.9,
beta_2=0.999)
由于 Adam 是一种自适应学习率算法,类似于 AdaGrad 和 RMSProp,它需要较少调整学习率超参数η。您通常可以使用默认值η=0.001,使得 Adam 比梯度下降更容易使用。
提示
如果您开始感到对所有这些不同技术感到不知所措,并想知道如何为您的任务选择合适的技术,不用担心:本章末尾提供了一些实用指南。
最后,值得一提的是 Adam 的三个变体:AdaMax、Nadam 和 AdamW。
AdaMax
Adam 论文还介绍了 AdaMax。请注意,在方程式 11-9 的第 2 步中,Adam 在s中累积梯度的平方(对于最近的梯度有更大的权重)。在第 5 步中,如果我们忽略ε和步骤 3 和 4(这些都是技术细节),Adam 通过s的平方根缩小参数更新。简而言之,Adam 通过时间衰减梯度的ℓ[2]范数缩小参数更新(回想一下,ℓ[2]范数是平方和的平方根)。
AdaMax 用ℓ[∞]范数(一种说法是最大值)替换了ℓ[2]范数。具体来说,它用替换了方程式 11-9 的第 2 步,删除了第 4 步,在第 5 步中,它通过s的因子缩小梯度更新,s是时间衰减梯度的绝对值的最大值。
实际上,这使得 AdaMax 比 Adam 更稳定,但这确实取决于数据集,总体上 Adam 表现更好。因此,如果您在某些任务上遇到 Adam 的问题,这只是另一个您可以尝试的优化器。
Nadam
Nadam 优化是 Adam 优化加上 Nesterov 技巧,因此它通常会比 Adam 收敛速度稍快。在介绍这种技术的研究报告中,研究员 Timothy Dozat 比较了许多不同的优化器在各种任务上的表现,发现 Nadam 通常优于 Adam,但有时会被 RMSProp 超越。
AdamW
AdamW是 Adam 的一个变体,它集成了一种称为权重衰减的正则化技术。权重衰减通过将模型的权重在每次训练迭代中乘以一个衰减因子,如 0.99,来减小权重的大小。这可能让您想起ℓ[2]正则化(在第四章介绍),它也旨在保持权重较小,事实上,可以在数学上证明,当使用 SGD 时,ℓ[2]正则化等效于权重衰减。然而,当使用 Adam 或其变体时,ℓ[2]正则化和权重衰减不等效:实际上,将 Adam 与ℓ[2]正则化结合使用会导致模型通常不如 SGD 产生的模型泛化能力好。AdamW 通过正确地将 Adam 与权重衰减结合来解决这个问题。
警告
自适应优化方法(包括 RMSProp、Adam、AdaMax、Nadam 和 AdamW 优化)通常很好,快速收敛到一个好的解决方案。然而,阿希亚·C·威尔逊等人在一篇2017 年的论文中表明,它们可能导致在某些数据集上泛化能力较差的解决方案。因此,当您对模型的性能感到失望时,请尝试使用 NAG:您的数据集可能只是对自适应梯度过敏。还要关注最新的研究,因为它发展迅速。
要在 Keras 中使用 Nadam、AdaMax 或 AdamW,请将tf.keras.optimizers.Adam替换为tf.keras.optimizers.Nadam、tf.keras.optimizers.Adamax或tf.keras.optimizers.experimental.AdamW。对于 AdamW,您可能需要调整weight_decay超参数。
到目前为止讨论的所有优化技术只依赖于一阶偏导数(雅可比)。优化文献中还包含基于二阶偏导数(海森,即雅可比的偏导数)的惊人算法。不幸的是,这些算法很难应用于深度神经网络,因为每个输出有n²个海森(其中n是参数的数量),而不是每个输出只有n个雅可比。由于 DNN 通常具有成千上万个参数甚至更多,第二阶优化算法通常甚至无法适应内存,即使能够适应,计算海森也太慢。
表 11-2 比较了到目前为止我们讨论过的所有优化器(是不好的,是平均的,是好的)。
表 11-2。优化器比较
| 类 |
收敛速度 |
收敛质量 |
SGD |
* |
*** |
SGD(momentum=...) |
** |
*** |
SGD(momentum=..., nesterov=True) |
** |
*** |
Adagrad |
*** |
*(过早停止) |
RMSprop |
*** |
** or *** |
Adam |
*** |
** or *** |
AdaMax |
*** |
** or *** |
Nadam |
*** |
** or *** |
AdamW |
*** |
** or *** |
学习率调度
找到一个好的学习率非常重要。如果设置得太高,训练可能会发散(如“梯度下降”中讨论的)。如果设置得太低,训练最终会收敛到最优解,但需要很长时间。如果设置得稍微偏高,它会在一开始就非常快地取得进展,但最终会围绕最优解打转,从未真正稳定下来。如果你的计算预算有限,你可能需要在训练收敛之前中断训练,得到一个次优解(参见图 11-9)。
![mls3 1109]()
图 11-9。不同学习率η的学习曲线
如第十章中讨论的,您可以通过训练模型几百次,将学习率从一个非常小的值指数增加到一个非常大的值,然后查看学习曲线并选择一个略低于学习曲线开始迅速上升的学习率来找到一个好的学习率。然后,您可以重新初始化您的模型,并使用该学习率进行训练。
但是你可以比恒定学习率做得更好:如果你从一个较大的学习率开始,然后在训练停止快速取得进展时降低它,你可以比使用最佳恒定学习率更快地达到一个好的解。有许多不同的策略可以在训练过程中降低学习率。从一个低学习率开始,增加它,然后再次降低它也可能是有益的。这些策略被称为学习计划(我在第四章中简要介绍了这个概念)。这些是最常用的学习计划:
幂调度
将学习率设置为迭代次数t的函数:η(t) = η[0] / (1 + t/s)^(c)。初始学习率η[0],幂c(通常设置为 1)和步长s是超参数。学习率在每一步下降。经过s步,学习率降至η[0]的一半。再经过s步,它降至η[0]的 1/3,然后降至η[0]的 1/4,然后η[0]的 1/5,依此类推。正如您所看到的,这个调度首先快速下降,然后变得越来越慢。当然,幂调度需要调整η[0]和s(可能还有c)。
指数调度
将学习率设置为η(t) = η[0] 0.1^(t/s)。学习率将每s步逐渐降低 10 倍。虽然幂调度使学习率降低得越来越慢,指数调度则每s步将其降低 10 倍。
分段常数调度
在一些时期内使用恒定的学习率(例如,η[0] = 0.1,持续 5 个时期),然后在另一些时期内使用较小的学习率(例如,η[1] = 0.001,持续 50 个时期),依此类推。尽管这种解决方案可能效果很好,但需要调整以找出正确的学习率序列以及每个学习率使用的时间长度。
性能调度
每N步测量验证错误(就像提前停止一样),当错误停止下降时,将学习率降低λ倍。
1cycle 调度
1cycle 是由 Leslie Smith 在2018 年的一篇论文中提出的。与其他方法相反,它从增加初始学习率η[0]开始,线性增长到训练中途的η[1]。然后在训练的第二半部分线性降低学习率至η[0],最后几个时期通过几个数量级的降低率(仍然是线性)来完成。最大学习率η[1]是使用我们用来找到最佳学习率的相同方法选择的,初始学习率η[0]通常低 10 倍。当使用动量时,我们首先使用高动量(例如 0.95),然后在训练的前半部分将其降低到较低的动量(例如 0.85,线性),然后在训练的后半部分将其提高到最大值(例如 0.95),最后几个时期使用该最大值。Smith 进行了许多实验,表明这种方法通常能够显著加快训练速度并达到更好的性能。例如,在流行的 CIFAR10 图像数据集上,这种方法仅在 100 个时期内达到了 91.9%的验证准确率,而通过标准方法(使用相同的神经网络架构)在 800 个时期内仅达到了 90.3%的准确率。这一壮举被称为超级收敛。
Andrew Senior 等人在2013 年的一篇论文中比较了使用动量优化训练深度神经网络进行语音识别时一些最流行的学习调度的性能。作者得出结论,在这种情况下,性能调度和指数调度表现良好。他们更青睐指数调度,因为它易于调整,并且收敛到最佳解稍快。他们还提到,它比性能调度更容易实现,但在 Keras 中,这两个选项都很容易。也就是说,1cycle 方法似乎表现得更好。
在 Keras 中实现幂调度是最简单的选择——只需在创建优化器时设置衰减超参数:
optimizer = tf.keras.optimizers.SGD(learning_rate=0.01, decay=1e-4)
衰减是s的倒数(将学习率除以一个单位所需的步数),Keras 假设c等于 1。
指数调度和分段调度也很简单。您首先需要定义一个函数,该函数接受当前 epoch 并返回学习率。例如,让我们实现指数调度:
def exponential_decay_fn(epoch):
return 0.01 * 0.1 ** (epoch / 20)
如果您不想硬编码 η[0] 和 s,您可以创建一个返回配置函数的函数:
def exponential_decay(lr0, s):
def exponential_decay_fn(epoch):
return lr0 * 0.1 ** (epoch / s)
return exponential_decay_fn
exponential_decay_fn = exponential_decay(lr0=0.01, s=20)
接下来,创建一个 LearningRateScheduler 回调,将调度函数传递给它,并将此回调传递给 fit() 方法:
lr_scheduler = tf.keras.callbacks.LearningRateScheduler(exponential_decay_fn)
history = model.fit(X_train, y_train, [...], callbacks=[lr_scheduler])
LearningRateScheduler 将在每个 epoch 开始时更新优化器的 learning_rate 属性。通常每个 epoch 更新一次学习率就足够了,但是如果您希望更频繁地更新它,例如在每一步,您可以随时编写自己的回调(请参阅本章笔记本中“指数调度”部分的示例)。在每一步更新学习率可能有助于处理每个 epoch 中的许多步骤。或者,您可以使用 tf.keras.optimizers.schedules 方法,稍后会进行描述。
提示
训练后,history.history["lr"] 可以让您访问训练过程中使用的学习率列表。
调度函数可以选择将当前学习率作为第二个参数。例如,以下调度函数将前一个学习率乘以 0.1^(1/20),这将导致相同的指数衰减(除了衰减现在从第 0 个 epoch 开始而不是第 1 个):
def exponential_decay_fn(epoch, lr):
return lr * 0.1 ** (1 / 20)
这个实现依赖于优化器的初始学习率(与之前的实现相反),所以请确保适当设置它。
当您保存一个模型时,优化器及其学习率也会被保存。这意味着使用这个新的调度函数,您可以加载一个训练好的模型,并继续在离开的地方继续训练,没有问题。然而,如果您的调度函数使用 epoch 参数,情况就不那么简单了:epoch 不会被保存,并且每次调用 fit() 方法时都会被重置为 0。如果您要继续训练一个模型,这可能会导致一个非常大的学习率,这可能会损坏模型的权重。一个解决方案是手动设置 fit() 方法的 initial_epoch 参数,使 epoch 从正确的值开始。
对于分段常数调度,您可以使用以下类似的调度函数(与之前一样,如果您愿意,您可以定义一个更通用的函数;请参阅笔记本中“分段常数调度”部分的示例),然后创建一个带有此函数的 LearningRateScheduler 回调,并将其传递给 fit() 方法,就像对指数调度一样:
def piecewise_constant_fn(epoch):
if epoch < 5:
return 0.01
elif epoch < 15:
return 0.005
else:
return 0.001
对于性能调度,请使用 ReduceLROnPlateau 回调。例如,如果您将以下回调传递给 fit() 方法,每当最佳验证损失连续五个 epoch 没有改善时,它将把学习率乘以 0.5(还有其他选项可用;请查看文档以获取更多详细信息):
lr_scheduler = tf.keras.callbacks.ReduceLROnPlateau(factor=0.5, patience=5)
history = model.fit(X_train, y_train, [...], callbacks=[lr_scheduler])
最后,Keras 提供了另一种实现学习率调度的方法:您可以使用 tf.keras.optimizers.schedules 中可用的类之一定义一个调度学习率,然后将其传递给任何优化器。这种方法在每一步而不是每个 epoch 更新学习率。例如,以下是如何实现与我们之前定义的 exponential_decay_fn() 函数相同的指数调度:
import math
batch_size = 32
n_epochs = 25
n_steps = n_epochs * math.ceil(len(X_train) / batch_size)
scheduled_learning_rate = tf.keras.optimizers.schedules.ExponentialDecay(
initial_learning_rate=0.01, decay_steps=n_steps, decay_rate=0.1)
optimizer = tf.keras.optimizers.SGD(learning_rate=scheduled_learning_rate)
这很简单明了,而且当您保存模型时,学习率及其调度(包括其状态)也会被保存。
至于 1cycle,Keras 不支持它,但是可以通过创建一个自定义回调,在每次迭代时修改学习率来实现它,代码不到 30 行。要从回调的 on_batch_begin() 方法中更新优化器的学习率,您需要调用 tf.keras.backend.set_value(self.model.optimizer.learning_rate, new_learning_rate)。请参阅笔记本中的“1Cycle Scheduling”部分以获取示例。
总之,指数衰减、性能调度和 1cycle 可以显著加快收敛速度,所以试一试吧!
通过正则化避免过拟合
有了四个参数,我可以拟合一只大象,有了五个我可以让它摇动它的鼻子。
约翰·冯·诺伊曼,引用自恩里科·费米在《自然》427 中
拥有成千上万个参数,你可以拟合整个动物园。深度神经网络通常有数万个参数,有时甚至有数百万个。这给予它们极大的自由度,意味着它们可以拟合各种复杂的数据集。但这种极大的灵活性也使得网络容易过拟合训练集。通常需要正则化来防止这种情况发生。
我们已经在第十章中实现了最好的正则化技术之一:提前停止。此外,即使批量归一化是为了解决不稳定梯度问题而设计的,它也像一个相当不错的正则化器。在本节中,我们将研究神经网络的其他流行正则化技术:ℓ[1] 和 ℓ[2] 正则化、dropout 和最大范数正则化。
ℓ[1] 和 ℓ[2] 正则化
就像你在第四章中为简单线性模型所做的那样,你可以使用 ℓ[2] 正则化来约束神经网络的连接权重,和/或者使用 ℓ[1] 正则化如果你想要一个稀疏模型(其中许多权重等于 0)。以下是如何将 ℓ[2] 正则化应用于 Keras 层的连接权重,使用正则化因子为 0.01:
layer = tf.keras.layers.Dense(100, activation="relu",
kernel_initializer="he_normal",
kernel_regularizer=tf.keras.regularizers.l2(0.01))
l2() 函数返回一个正则化器,在训练过程中的每一步都会调用它来计算正则化损失。然后将其添加到最终损失中。正如你所期望的那样,如果你想要 ℓ[1] 正则化,你可以简单地使用tf.keras.regularizers.l1();如果你想要同时使用 ℓ[1] 和 ℓ[2] 正则化,可以使用tf.keras.regularizers.l1_l2()(指定两个正则化因子)。
由于通常希望在网络的所有层中应用相同的正则化器,以及在所有隐藏层中使用相同的激活函数和相同的初始化策略,你可能会发现自己重复相同的参数。这会使代码变得丑陋且容易出错。为了避免这种情况,你可以尝试重构代码以使用循环。另一个选择是使用 Python 的functools.partial()函数,它允许你为任何可调用对象创建一个薄包装器,并设置一些默认参数值:
from functools import partial
RegularizedDense = partial(tf.keras.layers.Dense,
activation="relu",
kernel_initializer="he_normal",
kernel_regularizer=tf.keras.regularizers.l2(0.01))
model = tf.keras.Sequential([
tf.keras.layers.Flatten(input_shape=[28, 28]),
RegularizedDense(100),
RegularizedDense(100),
RegularizedDense(10, activation="softmax")
])
警告
正如我们之前看到的,当使用 SGD、动量优化和 Nesterov 动量优化时,ℓ[2] 正则化是可以的,但在使用 Adam 及其变种时不行。如果你想要在使用 Adam 时进行权重衰减,那么不要使用 ℓ[2] 正则化:使用 AdamW 替代。
Dropout
Dropout 是深度神经网络中最流行的正则化技术之一。它是由 Geoffrey Hinton 等人在 2012 年的一篇论文中提出的,并在 2014 年由 Nitish Srivastava 等人进一步详细阐述,已被证明非常成功:许多最先进的神经网络使用了 dropout,因为它使它们的准确率提高了 1%–2%。这听起来可能不多,但当一个模型已经有 95%的准确率时,获得 2%的准确率提升意味着将错误率减少了近 40%(从 5%的错误率降至大约 3%)。
这是一个相当简单的算法:在每个训练步骤中,每个神经元(包括输入神经元,但始终不包括输出神经元)都有一个概率p在训练期间暂时“被丢弃”,这意味着在这个训练步骤中它将被完全忽略,但在下一个步骤中可能会活跃(参见图 11-10)。超参数p称为dropout 率,通常设置在 10%到 50%之间:在循环神经网络中更接近 20%-30%(参见第十五章),在卷积神经网络中更接近 40%-50%(参见第十四章)。训练后,神经元不再被丢弃。这就是全部(除了我们将立即讨论的一个技术细节)。
最初令人惊讶的是,这种破坏性技术居然有效。如果一家公司告诉员工每天早上抛硬币决定是否去上班,公司会表现得更好吗?谁知道呢;也许会!公司将被迫调整其组织;它不能依赖任何一个人来操作咖啡机或执行其他关键任务,因此这种专业知识必须分散到几个人身上。员工必须学会与许多同事合作,而不仅仅是少数几个人。公司将变得更具弹性。如果有人离职,这不会有太大影响。目前尚不清楚这种想法是否适用于公司,但对于神经网络来说,它确实有效。使用 dropout 训练的神经元无法与其相邻的神经元共同适应;它们必须尽可能独立地发挥作用。它们也不能过度依赖少数输入神经元;它们必须关注每个输入神经元。它们最终对输入的轻微变化不太敏感。最终,您将获得一个更健壮的网络,具有更好的泛化能力。
![mls3 1110]()
图 11-10。使用 dropout 正则化,每次训练迭代中,一个或多个层中的所有神经元的随机子集(除了输出层)会“被丢弃”;这些神经元在这次迭代中输出为 0(由虚线箭头表示)
理解 dropout 的另一种方法是意识到在每个训练步骤中生成了一个独特的神经网络。由于每个神经元可以存在或不存在,因此存在 2^(N)个可能的网络(其中N是可丢弃神经元的总数)。这是一个如此巨大的数字,以至于同一个神经网络被重复抽样几乎是不可能的。一旦您运行了 10,000 个训练步骤,您实际上已经训练了 10,000 个不同的神经网络,每个神经网络只有一个训练实例。这些神经网络显然不是独立的,因为它们共享许多权重,但它们仍然是不同的。最终的神经网络可以看作是所有这些较小神经网络的平均集合。
提示
在实践中,通常只能将 dropout 应用于顶部一到三层的神经元(不包括输出层)。
有一个小但重要的技术细节。假设p=75%:平均每次训练步骤中只有 25%的神经元是活跃的。这意味着在训练后,神经元将连接到四倍于训练期间的输入神经元。为了补偿这一事实,我们需要在训练期间将每个神经元的输入连接权重乘以四。如果不这样做,神经网络在训练期间和训练后将看到不同的数据,表现不佳。更一般地,在训练期间,我们需要将连接权重除以“保留概率”(1-p)。
使用 Keras 实现 dropout,可以使用tf.keras.layers.Dropout层。在训练期间,它会随机丢弃一些输入(将它们设置为 0),并将剩余的输入除以保留概率。训练结束后,它什么也不做;它只是将输入传递给下一层。以下代码在每个密集层之前应用了 dropout 正则化,使用了 0.2 的 dropout 率:
model = tf.keras.Sequential([
tf.keras.layers.Flatten(input_shape=[28, 28]),
tf.keras.layers.Dropout(rate=0.2),
tf.keras.layers.Dense(100, activation="relu",
kernel_initializer="he_normal"),
tf.keras.layers.Dropout(rate=0.2),
tf.keras.layers.Dense(100, activation="relu",
kernel_initializer="he_normal"),
tf.keras.layers.Dropout(rate=0.2),
tf.keras.layers.Dense(10, activation="softmax")
])
[...] # compile and train the model
警告
由于 dropout 只在训练期间激活,比较训练损失和验证损失可能会产生误导。特别是,模型可能会过度拟合训练集,但训练和验证损失却相似。因此,请确保在没有 dropout 的情况下评估训练损失(例如,在训练后)。
如果观察到模型过拟合,可以增加 dropout 率。相反,如果模型对训练集拟合不足,可以尝试减少 dropout 率。对于大型层,增加 dropout 率,对于小型层,减少 dropout 率也有帮助。此外,许多最先进的架构仅在最后一个隐藏层之后使用 dropout,因此如果全局 dropout 太强,您可能想尝试这样做。
Dropout 确实会显著减慢收敛速度,但在适当调整后通常会得到更好的模型。因此,额外的时间和精力通常是值得的,特别是对于大型模型。
提示
如果要对基于 SELU 激活函数的自正则化网络进行正则化(如前面讨论的),应该使用alpha dropout:这是一种保留其输入均值和标准差的 dropout 变体。它是在与 SELU 一起引入的同一篇论文中提出的,因为常规 dropout 会破坏自正则化。
蒙特卡洛(MC)Dropout
2016 年,Yarin Gal 和 Zoubin Ghahramani 的一篇论文建立了使用 dropout 的更多好理由:
如果这一切听起来像某种“奇怪的技巧”点击诱饵,那么看看以下代码。这是 MC dropout 的完整实现,增强了我们之前训练的 dropout 模型而无需重新训练它:
import numpy as np
y_probas = np.stack([model(X_test, training=True)
for sample in range(100)])
y_proba = y_probas.mean(axis=0)
请注意,model(X)类似于model.predict(X),只是它返回一个张量而不是 NumPy 数组,并支持training参数。在这个代码示例中,设置training=True确保Dropout层保持活动状态,因此所有预测都会有些不同。我们只对测试集进行 100 次预测,并计算它们的平均值。更具体地说,每次调用模型都会返回一个矩阵,每个实例一行,每个类别一列。因为测试集中有 10,000 个实例和 10 个类别,所以这是一个形状为[10000, 10]的矩阵。我们堆叠了 100 个这样的矩阵,所以y_probas是一个形状为[100, 10000, 10]的 3D 数组。一旦我们在第一个维度上取平均值(axis=0),我们得到y_proba,一个形状为[10000, 10]的数组,就像我们在单次预测中得到的一样。就是这样!在打开 dropout 的情况下对多次预测取平均值会给我们一个通常比关闭 dropout 的单次预测结果更可靠的蒙特卡洛估计。例如,让我们看看模型对 Fashion MNIST 测试集中第一个实例的预测,关闭 dropout:
>>> model.predict(X_test[:1]).round(3)
array([[0\. , 0\. , 0\. , 0\. , 0\. , 0.024, 0\. , 0.132, 0\. ,
0.844]], dtype=float32)
模型相当自信(84.4%)这张图片属于第 9 类(踝靴)。与 MC dropout 预测进行比较:
>>> y_proba[0].round(3)
array([0\. , 0\. , 0\. , 0\. , 0\. , 0.067, 0\. , 0.209, 0.001,
0.723], dtype=float32)
模型似乎仍然更喜欢类别 9,但其置信度降至 72.3%,类别 5(凉鞋)和 7(运动鞋)的估计概率增加,这是有道理的,因为它们也是鞋类。
MC dropout 倾向于提高模型概率估计的可靠性。这意味着它不太可能自信但错误,这可能是危险的:想象一下一个自动驾驶汽车自信地忽略一个停车标志。了解哪些其他类别最有可能也很有用。此外,您可以查看概率估计的标准差:
>>> y_std = y_probas.std(axis=0)
>>> y_std[0].round(3)
array([0\. , 0\. , 0\. , 0.001, 0\. , 0.096, 0\. , 0.162, 0.001,
0.183], dtype=float32)
显然,类别 9 的概率估计存在相当大的方差:标准差为 0.183,应与估计的概率 0.723 进行比较:如果您正在构建一个风险敏感的系统(例如医疗或金融系统),您可能会对这种不确定的预测极为谨慎。您绝对不会将其视为 84.4%的自信预测。模型的准确性也从 87.0%略微提高到 87.2%:
>>> y_pred = y_proba.argmax(axis=1)
>>> accuracy = (y_pred == y_test).sum() / len(y_test)
>>> accuracy
0.8717
注意
您使用的蒙特卡洛样本数量(在此示例中为 100)是一个可以调整的超参数。它越高,预测和不确定性估计就越准确。但是,如果您将其加倍,推断时间也将加倍。此外,在一定数量的样本之上,您将注意到改进很小。您的任务是根据您的应用程序找到延迟和准确性之间的正确权衡。
如果您的模型包含在训练期间以特殊方式行为的其他层(例如BatchNormalization层),那么您不应该像我们刚刚做的那样强制训练模式。相反,您应该用以下MCDropout类替换Dropout层:³⁰
class MCDropout(tf.keras.layers.Dropout):
def call(self, inputs, training=False):
return super().call(inputs, training=True)
在这里,我们只是子类化Dropout层,并覆盖call()方法以强制其training参数为True(请参阅第十二章)。类似地,您可以通过子类化AlphaDropout来定义一个MCAlphaDropout类。如果您从头开始创建一个模型,只需使用MCDropout而不是Dropout。但是,如果您已经使用Dropout训练了一个模型,您需要创建一个与现有模型相同但使用Dropout而不是MCDropout的新模型,然后将现有模型的权重复制到新模型中。
简而言之,MC dropout 是一种很棒的技术,可以提升 dropout 模型并提供更好的不确定性估计。当然,由于在训练期间只是常规的 dropout,因此它也起到了正则化的作用。
最大范数正则化
神经网络的另一种流行的正则化技术称为最大范数正则化:对于每个神经元,它约束传入连接的权重w,使得∥ w ∥[2] ≤ r,其中r是最大范数超参数,∥ · ∥[2]是ℓ[2]范数。
最大范数正则化不会向整体损失函数添加正则化损失项。相反,通常是在每个训练步骤之后计算∥ w ∥[2],并在需要时重新缩放w(w ← w r / ∥ w ∥[2])。
减小r会增加正则化的程度,并有助于减少过拟合。最大范数正则化还可以帮助缓解不稳定的梯度问题(如果您没有使用批量归一化)。
在 Keras 中实现最大范数正则化,将每个隐藏层的kernel_constraint参数设置为具有适当最大值的max_norm()约束,如下所示:
dense = tf.keras.layers.Dense(
100, activation="relu", kernel_initializer="he_normal",
kernel_constraint=tf.keras.constraints.max_norm(1.))
在每次训练迭代之后,模型的fit()方法将调用max_norm()返回的对象,将该层的权重传递给它,并得到重新缩放的权重,然后替换该层的权重。正如您将在第十二章中看到的,如果需要,您可以定义自己的自定义约束函数,并将其用作kernel_constraint。您还可以通过设置bias_constraint参数来约束偏置项。
max_norm()函数有一个默认为0的axis参数。一个Dense层通常具有形状为[输入数量,神经元数量]的权重,因此使用axis=0意味着最大范数约束将独立应用于每个神经元的权重向量。如果您想在卷积层中使用最大范数(参见第十四章),请确保适当设置max_norm()约束的axis参数(通常为axis=[0, 1, 2])。
总结和实用指南
在本章中,我们涵盖了各种技术,您可能想知道应该使用哪些技术。这取决于任务,目前还没有明确的共识,但我发现表 11-3 中的配置在大多数情况下都能很好地工作,而不需要太多的超参数调整。尽管如此,请不要将这些默认值视为硬性规则!
表 11-3. 默认 DNN 配置
| 超参数 |
默认值 |
| 内核初始化器 |
He 初始化 |
| 激活函数 |
如果是浅层则为 ReLU;如果是深层则为 Swish |
| 归一化 |
如果是浅层则为无;如果是深层则为批量归一化 |
| 正则化 |
提前停止;如果需要则使用权重衰减 |
| 优化器 |
Nesterov 加速梯度或 AdamW |
| 学习率调度 |
性能调度或 1cycle |
如果网络是简单的密集层堆叠,则它可以自我归一化,您应该使用表 11-4 中的配置。
表 11-4. 自我归一化网络的 DNN 配置
| 超参数 |
默认值 |
| 内核初始化器 |
LeCun 初始化 |
| 激活函数 |
SELU |
| 归一化 |
无(自我归一化) |
| 正则化 |
如果需要则使用 Alpha dropout |
| 优化器 |
Nesterov 加速梯度 |
| 学习率调度 |
性能调度或 1cycle |
不要忘记对输入特征进行归一化!您还应尝试重用预训练神经网络的部分,如果您可以找到一个解决类似问题的模型,或者如果您有大量未标记数据,则使用无监督预训练,或者如果您有大量类似任务的标记数据,则使用辅助任务的预训练。
虽然前面的指南应该涵盖了大多数情况,但这里有一些例外情况:
-
如果您需要一个稀疏模型,您可以使用ℓ[1]正则化(并在训练后选择性地将微小权重归零)。如果您需要一个更稀疏的模型,您可以使用 TensorFlow 模型优化工具包。这将破坏自我归一化,因此在这种情况下应使用默认配置。
-
如果您需要一个低延迟模型(执行闪电般快速预测的模型),您可能需要使用更少的层,使用快速激活函数(如 ReLU 或 leaky ReLU),并在训练后将批量归一化层折叠到前面的层中。拥有一个稀疏模型也会有所帮助。最后,您可能希望将浮点精度从 32 位减少到 16 位甚至 8 位(参见“将模型部署到移动设备或嵌入式设备”)。再次,查看 TF-MOT。
-
如果您正在构建一个风险敏感的应用程序,或者推断延迟在您的应用程序中并不是非常重要,您可以使用 MC dropout 来提高性能,并获得更可靠的概率估计,以及不确定性估计。
有了这些指导,您现在已经准备好训练非常深的网络了!我希望您现在相信,只使用方便的 Keras API 就可以走很长一段路。然而,可能会有一天,当您需要更多控制时,例如编写自定义损失函数或调整训练算法时。对于这种情况,您将需要使用 TensorFlow 的较低级别 API,您将在下一章中看到。
练习
-
Glorot 初始化和 He 初始化旨在解决什么问题?
-
只要使用 He 初始化随机选择的值将所有权重初始化为相同值,这样做可以吗?
-
将偏置项初始化为 0 可以吗?
-
在本章讨论的每种激活函数中,您希望在哪些情况下使用?
-
当使用SGD优化器时,如果将momentum超参数设置得太接近 1(例如 0.99999),可能会发生什么?
-
列出三种可以生成稀疏模型的方法。
-
Dropout 会减慢训练速度吗?它会减慢推断速度(即对新实例进行预测)吗?MC dropout 呢?
-
练习在 CIFAR10 图像数据集上训练深度神经网络:
-
构建一个具有 20 个每层 100 个神经元的隐藏层的 DNN(这太多了,但这是这个练习的重点)。使用 He 初始化和 Swish 激活函数。
-
使用 Nadam 优化和提前停止,在 CIFAR10 数据集上训练网络。您可以使用tf.keras.datasets.cifar10.load_data()加载数据集。该数据集由 60,000 个 32×32 像素的彩色图像组成(50,000 个用于训练,10,000 个用于测试),具有 10 个类别,因此您需要一个具有 10 个神经元的 softmax 输出层。记得每次更改模型架构或超参数时都要搜索正确的学习率。
-
现在尝试添加批量归一化并比较学习曲线:它是否比以前收敛得更快?它是否产生更好的模型?它如何影响训练速度?
-
尝试用 SELU 替换批量归一化,并进行必要的调整以确保网络自我归一化(即标准化输入特征,使用 LeCun 正态初始化,确保 DNN 仅包含一系列密集层等)。
-
尝试使用 alpha dropout 对模型进行正则化。然后,在不重新训练模型的情况下,看看是否可以通过 MC dropout 获得更好的准确性。
-
使用 1cycle 调度重新训练您的模型,看看它是否提高了训练速度和模型准确性。
这些练习的解决方案可在本章笔记本的末尾找到,网址为https://homl.info/colab3。
¹ Xavier Glorot 和 Yoshua Bengio,“理解训练深度前馈神经网络的困难”,第 13 届人工智能和统计国际会议论文集(2010):249-256。
² 这里有一个类比:如果将麦克风放大器的旋钮调得太接近零,人们就听不到您的声音,但如果将其调得太接近最大值,您的声音将被饱和,人们将听不懂您在说什么。现在想象一下这样一系列放大器:它们都需要适当设置,以便您的声音在链的末端响亮清晰地传出。您的声音必须以与进入时相同的幅度从每个放大器中传出。
³ 例如,Kaiming He 等人,“深入研究整流器:在 ImageNet 分类上超越人类水平表现”,2015 年 IEEE 国际计算机视觉大会论文集(2015):1026-1034。
⁴ 如果神经元下面的层中的输入随时间演变并最终返回到 ReLU 激活函数再次获得正输入的范围内,死神经元可能会复活。例如,如果梯度下降调整了死神经元下面的神经元,这种情况可能会发生。
⁵ Bing Xu 等人,“卷积网络中修正激活的实证评估”,arXiv 预印本 arXiv:1505.00853(2015)。
⁶ Djork-Arné Clevert 等人,“指数线性单元(ELUs)快速准确的深度网络学习”,国际学习表示会议论文集,arXiv 预印本(2015 年)。
⁷ Günter Klambauer 等人,“自正则化神经网络”,第 31 届国际神经信息处理系统会议论文集(2017):972–981。
⁸ Dan Hendrycks 和 Kevin Gimpel,“高斯误差线性单元(GELUs)”,arXiv 预印本 arXiv:1606.08415(2016)。
⁹ 如果曲线上任意两点之间的线段永远不会低于曲线,则函数是凸的。单调函数只增加或只减少。
¹⁰ Prajit Ramachandran 等人,“寻找激活函数”,arXiv 预印本 arXiv:1710.05941(2017)。
¹¹ Diganta Misra,“Mish:一种自正则化的非单调激活函数”,arXiv 预印本 arXiv:1908.08681(2019)。
¹² Sergey Ioffe 和 Christian Szegedy,“批量归一化:通过减少内部协变量转移加速深度网络训练”,第 32 届国际机器学习会议论文集(2015):448–456。
¹³ 然而,它们是根据训练数据在训练期间估计的,因此可以说它们是可训练的。在 Keras 中,“不可训练”实际上意味着“不受反向传播影响”。
¹⁴ Razvan Pascanu 等人,“关于训练递归神经网络的困难”,第 30 届国际机器学习会议论文集(2013):1310–1318。
¹⁵ Boris T. Polyak,“加速迭代方法收敛的一些方法”,苏联计算数学和数学物理杂志 4,第 5 期(1964):1–17。
¹⁶ Yurii Nesterov,“一种具有收敛速率O(1/k²)的无约束凸最小化问题方法”,苏联科学院学报 269(1983):543–547。
¹⁷ John Duchi 等人,“用于在线学习和随机优化的自适应次梯度方法”,机器学习研究杂志 12(2011):2121–2159。
¹⁸ 该算法由 Geoffrey Hinton 和 Tijmen Tieleman 于 2012 年创建,并由 Geoffrey Hinton 在他关于神经网络的 Coursera 课程中介绍(幻灯片:https://homl.info/57;视频:https://homl.info/58)。有趣的是,由于作者没有撰写描述该算法的论文,研究人员经常在其论文中引用“第 6e 讲座的第 29 张幻灯片”。
¹⁹ ρ是希腊字母 rho。
²⁰ Diederik P. Kingma 和 Jimmy Ba,“Adam:一种随机优化方法”,arXiv 预印本 arXiv:1412.6980(2014)。
²¹ Timothy Dozat,“将 Nesterov 动量合并到 Adam 中”(2016)。
²² Ilya Loshchilov 和 Frank Hutter,“解耦权重衰减正则化”,arXiv 预印本 arXiv:1711.05101(2017)。
²³ Ashia C. Wilson 等人,“机器学习中自适应梯度方法的边际价值”,神经信息处理系统进展 30(2017):4148–4158。
Leslie N. Smith,“神经网络超参数的纪律性方法:第 1 部分—学习率、批量大小、动量和权重衰减”,arXiv 预印本 arXiv:1803.09820(2018)。
Andrew Senior 等人,“深度神经网络在语音识别中的学习率的实证研究”,IEEE 国际会议论文集(2013):6724–6728。
Geoffrey E. Hinton 等人,“通过防止特征探测器的共适应来改进神经网络”,arXiv 预印本 arXiv:1207.0580(2012)。
Nitish Srivastava 等人,“Dropout:防止神经网络过拟合的简单方法”,机器学习研究杂志 15(2014):1929–1958。
Yarin Gal 和 Zoubin Ghahramani,“Dropout 作为贝叶斯近似:在深度学习中表示模型不确定性”,第 33 届国际机器学习会议论文集(2016):1050–1059。
具体来说,他们表明训练一个 dropout 网络在数学上等同于在一种特定类型的概率模型中进行近似贝叶斯推断,这种模型被称为深高斯过程。
这个MCDropout类将与所有 Keras API 一起工作,包括顺序 API。如果您只关心功能 API 或子类 API,您不必创建一个MCDropout类;您可以创建一个常规的Dropout层,并使用training=True调用它。
第十二章:使用 TensorFlow 进行自定义模型和训练
到目前为止,我们只使用了 TensorFlow 的高级 API,Keras,但它已经让我们走得很远:我们构建了各种神经网络架构,包括回归和分类网络,Wide & Deep 网络,自正则化网络,使用各种技术,如批量归一化,dropout 和学习率调度。事实上,您将遇到的 95%用例不需要除了 Keras(和 tf.data)之外的任何东西(请参见第十三章)。但现在是时候深入研究 TensorFlow,看看它的低级Python API。当您需要额外控制以编写自定义损失函数,自定义指标,层,模型,初始化程序,正则化器,权重约束等时,这将非常有用。您甚至可能需要完全控制训练循环本身;例如,应用特殊的转换或约束到梯度(超出仅仅剪切它们)或为网络的不同部分使用多个优化器。我们将在本章中涵盖所有这些情况,并且还将看看如何使用 TensorFlow 的自动生成图功能来提升您的自定义模型和训练算法。但首先,让我们快速浏览一下 TensorFlow。
TensorFlow 的快速浏览
正如您所知,TensorFlow 是一个强大的用于数值计算的库,特别适用于大规模机器学习(但您也可以用它来进行需要大量计算的任何其他任务)。它由 Google Brain 团队开发,驱动了谷歌许多大规模服务,如 Google Cloud Speech,Google Photos 和 Google Search。它于 2015 年 11 月开源,现在是业界最广泛使用的深度学习库:无数项目使用 TensorFlow 进行各种机器学习任务,如图像分类,自然语言处理,推荐系统和时间序列预测。
那么 TensorFlow 提供了什么?以下是一个摘要:
-
它的核心与 NumPy 非常相似,但支持 GPU。
-
它支持分布式计算(跨多个设备和服务器)。
-
它包括一种即时(JIT)编译器,允许它优化计算以提高速度和内存使用。它通过从 Python 函数中提取计算图,优化它(例如通过修剪未使用的节点),并有效地运行它(例如通过自动并行运行独立操作)来工作。
-
计算图可以导出为可移植格式,因此您可以在一个环境中训练 TensorFlow 模型(例如在 Linux 上使用 Python),并在另一个环境中运行它(例如在 Android 设备上使用 Java)。
-
它实现了反向模式自动微分(请参见第十章和附录 B)并提供了一些优秀的优化器,如 RMSProp 和 Nadam(请参见第十一章),因此您可以轻松最小化各种损失函数。
TensorFlow 提供了许多建立在这些核心功能之上的功能:最重要的当然是 Keras,但它还有数据加载和预处理操作(tf.data,tf.io 等),图像处理操作(tf.image),信号处理操作(tf.signal)等等(请参见图 12-1 以获取 TensorFlow 的 Python API 概述)。
提示
我们将涵盖 TensorFlow API 的许多包和函数,但不可能覆盖所有内容,因此您应该花些时间浏览 API;您会发现它非常丰富且有很好的文档。
在最低级别上,每个 TensorFlow 操作(简称 op)都是使用高效的 C++代码实现的。许多操作有多个称为内核的实现:每个内核专门用于特定设备类型,如 CPU、GPU,甚至 TPU(张量处理单元)。正如您可能知道的,GPU 可以通过将计算分成许多较小的块并在许多 GPU 线程上并行运行来显着加快计算速度。TPU 速度更快:它们是专门用于深度学习操作的定制 ASIC 芯片(我们将在第十九章讨论如何使用 GPU 或 TPU 与 TensorFlow)。
![mls3 1201]()
图 12-1. TensorFlow 的 Python API
TensorFlow 的架构如图 12-2 所示。大部分时间,您的代码将使用高级 API(特别是 Keras 和 tf.data),但当您需要更灵活性时,您将使用较低级别的 Python API,直接处理张量。无论如何,TensorFlow 的执行引擎将有效地运行操作,即使跨多个设备和机器,如果您告诉它的话。
TensorFlow 不仅可以在 Windows、Linux 和 macOS 上运行,还可以在移动设备上运行(使用 TensorFlow Lite),包括 iOS 和 Android(请参阅第十九章)。请注意,如果您不想使用 Python API,还可以使用其他语言的 API:有 C++、Java 和 Swift 的 API。甚至还有一个名为 TensorFlow.js 的 JavaScript 实现,可以直接在浏览器中运行您的模型。
![mls3 1202]()
图 12-2. TensorFlow 的架构
TensorFlow 不仅仅是一个库。TensorFlow 是一个庞大生态系统中心。首先,有用于可视化的 TensorBoard(请参阅第十章)。接下来,有由 Google 构建的用于将 TensorFlow 项目投入生产的一套库,称为TensorFlow Extended (TFX):它包括用于数据验证、预处理、模型分析和服务的工具(使用 TF Serving;请参阅第十九章)。Google 的 TensorFlow Hub 提供了一种轻松下载和重复使用预训练神经网络的方式。您还可以在 TensorFlow 的model garden中获得许多神经网络架构,其中一些是预训练的。查看TensorFlow 资源和https://github.com/jtoy/awesome-tensorflow以获取更多基于 TensorFlow 的项目。您可以在 GitHub 上找到数百个 TensorFlow 项目,因此通常很容易找到您正在尝试做的任何事情的现有代码。
提示
越来越多的机器学习论文随着它们的实现发布,有时甚至附带预训练模型。请查看https://paperswithcode.com以轻松找到它们。
最后但并非最不重要的是,TensorFlow 拥有一支充满激情和乐于助人的开发团队,以及一个庞大的社区为其改进做出贡献。要提出技术问题,您应该使用https://stackoverflow.com,并在问题中标记tensorflow和python。您可以通过GitHub提交错误和功能请求。要进行一般讨论,请加入TensorFlow 论坛。
好了,现在是开始编码的时候了!
像 NumPy 一样使用 TensorFlow
TensorFlow 的 API 围绕着张量展开,这些张量从操作流向操作,因此得名 TensorFlow。张量与 NumPy 的ndarray非常相似:通常是一个多维数组,但也可以保存标量(例如42)。当我们创建自定义成本函数、自定义指标、自定义层等时,这些张量将非常重要,让我们看看如何创建和操作它们。
张量和操作
您可以使用tf.constant()创建一个张量。例如,这里是一个表示具有两行三列浮点数的矩阵的张量:
>>> import tensorflow as tf
>>> t = tf.constant([[1., 2., 3.], [4., 5., 6.]]) # matrix
>>> t
<tf.Tensor: shape=(2, 3), dtype=float32, numpy=
array([[1., 2., 3.],
[4., 5., 6.]], dtype=float32)>
就像ndarray一样,tf.Tensor有一个形状和一个数据类型(dtype):
>>> t.shape
TensorShape([2, 3])
>>> t.dtype
tf.float32
索引工作方式与 NumPy 类似:
>>> t[:, 1:]
<tf.Tensor: shape=(2, 2), dtype=float32, numpy=
array([[2., 3.],
[5., 6.]], dtype=float32)>
>>> t[..., 1, tf.newaxis]
<tf.Tensor: shape=(2, 1), dtype=float32, numpy=
array([[2.],
[5.]], dtype=float32)>
最重要的是,各种张量操作都是可用的:
>>> t + 10
<tf.Tensor: shape=(2, 3), dtype=float32, numpy=
array([[11., 12., 13.],
[14., 15., 16.]], dtype=float32)>
>>> tf.square(t)
<tf.Tensor: shape=(2, 3), dtype=float32, numpy=
array([[ 1., 4., 9.],
[16., 25., 36.]], dtype=float32)>
>>> t @ tf.transpose(t)
<tf.Tensor: shape=(2, 2), dtype=float32, numpy=
array([[14., 32.],
[32., 77.]], dtype=float32)>
请注意,编写t + 10等同于调用tf.add(t, 10)(实际上,Python 调用了魔术方法t.__add__(10),它只是调用了tf.add(t, 10))。其他运算符,如-和*,也受支持。@运算符在 Python 3.5 中添加,用于矩阵乘法:它等同于调用tf.matmul()函数。
注意
许多函数和类都有别名。例如,tf.add()和tf.math.add()是相同的函数。这使得 TensorFlow 可以为最常见的操作保留简洁的名称,同时保持良好组织的包。
张量也可以保存标量值。在这种情况下,形状为空:
>>> tf.constant(42)
<tf.Tensor: shape=(), dtype=int32, numpy=42>
注意
Keras API 有自己的低级 API,位于tf.keras.backend中。这个包通常被导入为K,以简洁为主。它曾经包括函数如K.square()、K.exp()和K.sqrt(),您可能在现有代码中遇到:这在 Keras 支持多个后端时编写可移植代码很有用,但现在 Keras 只支持 TensorFlow,您应该直接调用 TensorFlow 的低级 API(例如,使用tf.square()而不是K.square())。从技术上讲,K.square()及其相关函数仍然存在以保持向后兼容性,但tf.keras.backend包的文档只列出了一些实用函数,例如clear_session()(在第十章中提到)。
您将找到所有您需要的基本数学运算(tf.add()、tf.multiply()、tf.square()、tf.exp()、tf.sqrt()等)以及大多数您可以在 NumPy 中找到的操作(例如tf.reshape()、tf.squeeze()、tf.tile())。一些函数的名称与 NumPy 中的名称不同;例如,tf.reduce_mean()、tf.reduce_sum()、tf.reduce_max()和tf.math.log()相当于np.mean()、np.sum()、np.max()和np.log()。当名称不同时,通常有很好的理由。例如,在 TensorFlow 中,您必须编写tf.transpose(t);您不能像在 NumPy 中那样只写t.T。原因是tf.transpose()函数与 NumPy 的T属性并不完全相同:在 TensorFlow 中,将创建一个具有其自己的转置数据副本的新张量,而在 NumPy 中,t.T只是相同数据的一个转置视图。同样,tf.reduce_sum()操作之所以被命名为这样,是因为其 GPU 核心(即 GPU 实现)使用的减少算法不保证元素添加的顺序:因为 32 位浮点数的精度有限,每次调用此操作时结果可能会发生微小变化。tf.reduce_mean()也是如此(当然tf.reduce_max()是确定性的)。
张量和 NumPy
张量与 NumPy 兼容:您可以从 NumPy 数组创建张量,反之亦然。您甚至可以将 TensorFlow 操作应用于 NumPy 数组,将 NumPy 操作应用于张量:
>>> import numpy as np
>>> a = np.array([2., 4., 5.])
>>> tf.constant(a)
<tf.Tensor: id=111, shape=(3,), dtype=float64, numpy=array([2., 4., 5.])>
>>> t.numpy() # or np.array(t)
array([[1., 2., 3.],
[4., 5., 6.]], dtype=float32)
>>> tf.square(a)
<tf.Tensor: id=116, shape=(3,), dtype=float64, numpy=array([4., 16., 25.])>
>>> np.square(t)
array([[ 1., 4., 9.],
[16., 25., 36.]], dtype=float32)
警告
请注意,NumPy 默认使用 64 位精度,而 TensorFlow 使用 32 位。这是因为 32 位精度通常对神经网络来说足够了,而且运行速度更快,使用的内存更少。因此,当您从 NumPy 数组创建张量时,请确保设置dtype=tf.float32。
类型转换
类型转换可能会严重影响性能,并且当它们自动完成时很容易被忽略。为了避免这种情况,TensorFlow 不会自动执行任何类型转换:如果您尝试在具有不兼容类型的张量上执行操作,它只会引发异常。例如,您不能将浮点张量和整数张量相加,甚至不能将 32 位浮点数和 64 位浮点数相加:
>>> tf.constant(2.) + tf.constant(40)
[...] InvalidArgumentError: [...] expected to be a float tensor [...]
>>> tf.constant(2.) + tf.constant(40., dtype=tf.float64)
[...] InvalidArgumentError: [...] expected to be a float tensor [...]
这可能一开始有点烦人,但请记住这是为了一个好的目的!当然,当您真正需要转换类型时,您可以使用tf.cast():
>>> t2 = tf.constant(40., dtype=tf.float64)
>>> tf.constant(2.0) + tf.cast(t2, tf.float32)
<tf.Tensor: id=136, shape=(), dtype=float32, numpy=42.0>
变量
到目前为止,我们看到的tf.Tensor值是不可变的:我们无法修改它们。这意味着我们不能使用常规张量来实现神经网络中的权重,因为它们需要通过反向传播进行调整。此外,其他参数可能也需要随时间变化(例如,动量优化器会跟踪过去的梯度)。我们需要的是tf.Variable:
>>> v = tf.Variable([[1., 2., 3.], [4., 5., 6.]])
>>> v
<tf.Variable 'Variable:0' shape=(2, 3) dtype=float32, numpy=
array([[1., 2., 3.],
[4., 5., 6.]], dtype=float32)>
tf.Variable的行为很像tf.Tensor:您可以执行相同的操作,它与 NumPy 很好地配合,对类型也一样挑剔。但是它也可以使用assign()方法(或assign_add()或assign_sub(),它们会增加或减少给定值来就地修改变量)。您还可以使用单个单元格(或切片)的assign()方法或使用scatter_update()或scatter_nd_update()方法来修改单个单元格(或切片):
v.assign(2 * v) # v now equals [[2., 4., 6.], [8., 10., 12.]]
v[0, 1].assign(42) # v now equals [[2., 42., 6.], [8., 10., 12.]]
v[:, 2].assign([0., 1.]) # v now equals [[2., 42., 0.], [8., 10., 1.]]
v.scatter_nd_update( # v now equals [[100., 42., 0.], [8., 10., 200.]]
indices=[[0, 0], [1, 2]], updates=[100., 200.])
直接赋值不起作用:
>>> v[1] = [7., 8., 9.]
[...] TypeError: 'ResourceVariable' object does not support item assignment
注意
在实践中,您很少需要手动创建变量;Keras 提供了一个add_weight()方法,它会为您处理,您将看到。此外,模型参数通常会直接由优化器更新,因此您很少需要手动更新变量。
其他数据结构
TensorFlow 支持几种其他数据结构,包括以下内容(请参阅本章笔记本中的“其他数据结构”部分或附录 C 了解更多详细信息):
稀疏张量(tf.SparseTensor)
高效地表示大部分为零的张量。tf.sparse包含了稀疏张量的操作。
张量数组(tf.TensorArray)
是张量列表。它们默认具有固定长度,但可以选择性地扩展。它们包含的所有张量必须具有相同的形状和数据类型。
不规则张量(tf.RaggedTensor)
表示张量列表,所有张量的秩和数据类型相同,但大小不同。张量大小变化的维度称为不规则维度。tf.ragged包含了不规则张量的操作。
字符串张量
是类型为tf.string的常规张量。这些表示字节字符串,而不是 Unicode 字符串,因此如果您使用 Unicode 字符串(例如,像"café"这样的常规 Python 3 字符串)创建字符串张量,那么它将自编码为 UTF-8(例如,b"caf\xc3\xa9")。或者,您可以使用类型为tf.int32的张量来表示 Unicode 字符串,其中每个项目表示一个 Unicode 代码点(例如,[99, 97, 102, 233])。tf.strings包(带有s)包含用于字节字符串和 Unicode 字符串的操作(以及将一个转换为另一个的操作)。重要的是要注意tf.string是原子的,这意味着其长度不会出现在张量的形状中。一旦您将其转换为 Unicode 张量(即,一个包含 Unicode 代码点的tf.int32类型的张量),长度将出现在形状中。
集合
表示为常规张量(或稀疏张量)。例如,tf.constant([[1, 2], [3, 4]])表示两个集合{1, 2}和{3, 4}。更一般地,每个集合由张量的最后一个轴中的向量表示。您可以使用tf.sets包中的操作来操作集合。
队列
在多个步骤中存储张量。TensorFlow 提供各种类型的队列:基本的先进先出(FIFO)队列(FIFOQueue),以及可以优先处理某些项目的队列(PriorityQueue),对其项目进行洗牌的队列(RandomShuffleQueue),以及通过填充来批处理不同形状的项目的队列(PaddingFIFOQueue)。这些类都在tf.queue包中。
有了张量、操作、变量和各种数据结构,你现在可以定制你的模型和训练算法了!
自定义模型和训练算法
你将首先创建一个自定义损失函数,这是一个简单而常见的用例。
自定义损失函数
假设你想训练一个回归模型,但你的训练集有点嘈杂。当然,你首先尝试通过删除或修复异常值来清理数据集,但结果还不够好;数据集仍然很嘈杂。你应该使用哪种损失函数?均方误差可能会过分惩罚大误差,导致模型不够精确。平均绝对误差不会像惩罚异常值那样严重,但训练可能需要一段时间才能收敛,训练出的模型可能不够精确。这可能是使用 Huber 损失的好时机(在第十章介绍)。Huber 损失在 Keras 中是可用的(只需使用tf.keras.losses.Huber类的实例),但让我们假装它不存在。要实现它,只需创建一个函数,该函数将标签和模型预测作为参数,并使用 TensorFlow 操作来计算包含所有损失的张量(每个样本一个):
def huber_fn(y_true, y_pred):
error = y_true - y_pred
is_small_error = tf.abs(error) < 1
squared_loss = tf.square(error) / 2
linear_loss = tf.abs(error) - 0.5
return tf.where(is_small_error, squared_loss, linear_loss)
警告
为了获得更好的性能,你应该使用矢量化的实现,就像这个例子一样。此外,如果你想要从 TensorFlow 的图优化功能中受益,你应该只使用 TensorFlow 操作。
也可以返回平均损失而不是单个样本损失,但这不推荐,因为这样做会使在需要时无法使用类权重或样本权重(参见第十章)。
现在你可以在编译 Keras 模型时使用这个 Huber 损失函数,然后像往常一样训练你的模型:
model.compile(loss=huber_fn, optimizer="nadam")
model.fit(X_train, y_train, [...])
就是这样!在训练期间的每个批次中,Keras 将调用huber_fn()函数来计算损失,然后使用反向模式自动微分来计算损失相对于所有模型参数的梯度,最后执行梯度下降步骤(在这个例子中使用 Nadam 优化器)。此外,它将跟踪自从 epoch 开始以来的总损失,并显示平均损失。
但是当你保存模型时,这个自定义损失会发生什么?
保存和加载包含自定义组件的模型
保存包含自定义损失函数的模型可以正常工作,但是当你加载它时,你需要提供一个将函数名称映射到实际函数的字典。更一般地,当你加载包含自定义对象的模型时,你需要将名称映射到对象:
model = tf.keras.models.load_model("my_model_with_a_custom_loss",
custom_objects={"huber_fn": huber_fn})
提示
如果你用@keras.utils.register_keras_serializable()装饰huber_fn()函数,它将自动可用于load_model()函数:不需要将其包含在custom_objects字典中。
使用当前的实现,任何在-1 和 1 之间的错误都被认为是“小”。但是如果你想要一个不同的阈值呢?一个解决方案是创建一个函数来创建一个配置好的损失函数:
def create_huber(threshold=1.0):
def huber_fn(y_true, y_pred):
error = y_true - y_pred
is_small_error = tf.abs(error) < threshold
squared_loss = tf.square(error) / 2
linear_loss = threshold * tf.abs(error) - threshold ** 2 / 2
return tf.where(is_small_error, squared_loss, linear_loss)
return huber_fn
model.compile(loss=create_huber(2.0), optimizer="nadam")
不幸的是,当你保存模型时,threshold不会被保存。这意味着在加载模型时你将需要指定threshold的值(注意要使用的名称是"huber_fn",这是你给 Keras 的函数的名称,而不是创建它的函数的名称):
model = tf.keras.models.load_model(
"my_model_with_a_custom_loss_threshold_2",
custom_objects={"huber_fn": create_huber(2.0)}
)
你可以通过创建tf.keras.losses.Loss类的子类,然后实现它的get_config()方法来解决这个问题:
class HuberLoss(tf.keras.losses.Loss):
def __init__(self, threshold=1.0, **kwargs):
self.threshold = threshold
super().__init__(**kwargs)
def call(self, y_true, y_pred):
error = y_true - y_pred
is_small_error = tf.abs(error) < self.threshold
squared_loss = tf.square(error) / 2
linear_loss = self.threshold * tf.abs(error) - self.threshold**2 / 2
return tf.where(is_small_error, squared_loss, linear_loss)
def get_config(self):
base_config = super().get_config()
return {**base_config, "threshold": self.threshold}
让我们来看看这段代码:
-
构造函数接受**kwargs并将它们传递给父构造函数,父构造函数处理标准超参数:损失的name和用于聚合单个实例损失的reduction算法。默认情况下,这是"AUTO",等同于"SUM_OVER_BATCH_SIZE":损失将是实例损失的总和,加权后再除以批量大小(而不是加权平均)。其他可能的值是"SUM"和"NONE"。
-
call()方法接受标签和预测值,计算所有实例损失,并返回它们。
-
get_config()方法返回一个字典,将每个超参数名称映射到其值。它首先调用父类的get_config()方法,然后将新的超参数添加到此字典中。
然后您可以在编译模型时使用此类的任何实例:
model.compile(loss=HuberLoss(2.), optimizer="nadam")
当您保存模型时,阈值将与模型一起保存;当您加载模型时,您只需要将类名映射到类本身:
model = tf.keras.models.load_model("my_model_with_a_custom_loss_class",
custom_objects={"HuberLoss": HuberLoss})
当您保存模型时,Keras 会调用损失实例的get_config()方法,并以 SavedModel 格式保存配置。当您加载模型时,它会在HuberLoss类上调用from_config()类方法:这个方法由基类(Loss)实现,并创建一个类的实例,将**config传递给构造函数。
损失就是这样了!正如您现在将看到的,自定义激活函数、初始化器、正则化器和约束并没有太大不同。
自定义激活函数、初始化器、正则化器和约束
大多数 Keras 功能,如损失、正则化器、约束、初始化器、指标、激活函数、层,甚至完整模型,都可以以类似的方式进行自定义。大多数情况下,您只需要编写一个带有适当输入和输出的简单函数。这里有一个自定义激活函数的示例(相当于tf.keras.activations.softplus()或tf.nn.softplus())、一个自定义 Glorot 初始化器的示例(相当于tf.keras.initializers.glorot_normal())、一个自定义ℓ[1]正则化器的示例(相当于tf.keras.regularizers.l1(0.01))以及一个确保权重都为正的自定义约束的示例(相当于tf.keras.constraints.nonneg()或tf.nn.relu()):
def my_softplus(z):
return tf.math.log(1.0 + tf.exp(z))
def my_glorot_initializer(shape, dtype=tf.float32):
stddev = tf.sqrt(2. / (shape[0] + shape[1]))
return tf.random.normal(shape, stddev=stddev, dtype=dtype)
def my_l1_regularizer(weights):
return tf.reduce_sum(tf.abs(0.01 * weights))
def my_positive_weights(weights): # return value is just tf.nn.relu(weights)
return tf.where(weights < 0., tf.zeros_like(weights), weights)
正如您所看到的,参数取决于自定义函数的类型。然后可以像这里展示的那样正常使用这些自定义函数:
layer = tf.keras.layers.Dense(1, activation=my_softplus,
kernel_initializer=my_glorot_initializer,
kernel_regularizer=my_l1_regularizer,
kernel_constraint=my_positive_weights)
激活函数将应用于此Dense层的输出,并将其结果传递给下一层。层的权重将使用初始化器返回的值进行初始化。在每个训练步骤中,权重将传递给正则化函数以计算正则化损失,然后将其添加到主损失中以获得用于训练的最终损失。最后,在每个训练步骤之后,将调用约束函数,并将层的权重替换为受约束的权重。
如果一个函数有需要与模型一起保存的超参数,那么您将希望子类化适当的类,比如tf.keras.regularizers.Regularizer、tf.keras.constraints.Constraint、tf.keras.initializers.Initializer或tf.keras.layers.Layer(适用于任何层,包括激活函数)。就像您为自定义损失所做的那样,这里是一个简单的ℓ[1]正则化类,它保存了其factor超参数(这次您不需要调用父构造函数或get_config()方法,因为它们不是由父类定义的):
class MyL1Regularizer(tf.keras.regularizers.Regularizer):
def __init__(self, factor):
self.factor = factor
def __call__(self, weights):
return tf.reduce_sum(tf.abs(self.factor * weights))
def get_config(self):
return {"factor": self.factor}
请注意,您必须为损失、层(包括激活函数)和模型实现call()方法,或者为正则化器、初始化器和约束实现__call__()方法。对于指标,情况有些不同,您将立即看到。
自定义指标
损失和指标在概念上并不相同:损失(例如,交叉熵)被梯度下降用来训练模型,因此它们必须是可微的(至少在评估它们的点上),它们的梯度不应该在任何地方都为零。此外,如果它们不容易被人类解释也是可以的。相反,指标(例如,准确率)用于评估模型:它们必须更容易被解释,可以是不可微的或者在任何地方梯度为零。
也就是说,在大多数情况下,定义一个自定义指标函数与定义一个自定义损失函数完全相同。实际上,我们甚至可以使用我们之前创建的 Huber 损失函数作为指标;它会工作得很好(在这种情况下,持久性也会以相同的方式工作,只保存函数的名称"huber_fn",而不是阈值):
model.compile(loss="mse", optimizer="nadam", metrics=[create_huber(2.0)])
在训练期间的每个批次,Keras 将计算这个指标并跟踪自开始时的平均值。大多数情况下,这正是你想要的。但并非总是如此!例如,考虑一个二元分类器的精度。正如你在第三章中看到的,精度是真正例的数量除以正例的预测数量(包括真正例和假正例)。假设模型在第一个批次中做出了五个正面预测,其中四个是正确的:这是 80%的精度。然后假设模型在第二个批次中做出了三个正面预测,但它们全部是错误的:这是第二个批次的 0%精度。如果你只计算这两个精度的平均值,你会得到 40%。但等一下——这不是这两个批次的模型精度!事实上,总共有四个真正例(4 + 0)中的八个正面预测(5 + 3),所以总体精度是 50%,而不是 40%。我们需要的是一个对象,它可以跟踪真正例的数量和假正例的数量,并且可以在需要时基于这些数字计算精度。这正是tf.keras.metrics.Precision类所做的:
>>> precision = tf.keras.metrics.Precision()
>>> precision([0, 1, 1, 1, 0, 1, 0, 1], [1, 1, 0, 1, 0, 1, 0, 1])
<tf.Tensor: shape=(), dtype=float32, numpy=0.8>
>>> precision([0, 1, 0, 0, 1, 0, 1, 1], [1, 0, 1, 1, 0, 0, 0, 0])
<tf.Tensor: shape=(), dtype=float32, numpy=0.5>
在这个例子中,我们创建了一个Precision对象,然后像一个函数一样使用它,为第一个批次传递标签和预测,然后为第二个批次(如果需要,还可以传递样本权重)。我们使用了与刚才讨论的示例中相同数量的真正例和假正例。在第一个批次之后,它返回 80%的精度;然后在第二个批次之后,它返回 50%(这是到目前为止的总体精度,而不是第二个批次的精度)。这被称为流式指标(或有状态指标),因为它逐渐更新,批次之后。
在任何时候,我们可以调用result()方法来获取指标的当前值。我们还可以通过使用variables属性查看其变量(跟踪真正例和假正例的数量),并可以使用reset_states()方法重置这些变量:
>>> precision.result()
<tf.Tensor: shape=(), dtype=float32, numpy=0.5>
>>> precision.variables
[<tf.Variable 'true_positives:0' [...], numpy=array([4.], dtype=float32)>,
<tf.Variable 'false_positives:0' [...], numpy=array([4.], dtype=float32)>]
>>> precision.reset_states() # both variables get reset to 0.0
如果需要定义自己的自定义流式指标,创建tf.keras.metrics.Metric类的子类。这里是一个基本示例,它跟踪总 Huber 损失和迄今为止看到的实例数量。当要求结果时,它返回比率,这只是平均 Huber 损失:
class HuberMetric(tf.keras.metrics.Metric):
def __init__(self, threshold=1.0, **kwargs):
super().__init__(**kwargs) # handles base args (e.g., dtype)
self.threshold = threshold
self.huber_fn = create_huber(threshold)
self.total = self.add_weight("total", initializer="zeros")
self.count = self.add_weight("count", initializer="zeros")
def update_state(self, y_true, y_pred, sample_weight=None):
sample_metrics = self.huber_fn(y_true, y_pred)
self.total.assign_add(tf.reduce_sum(sample_metrics))
self.count.assign_add(tf.cast(tf.size(y_true), tf.float32))
def result(self):
return self.total / self.count
def get_config(self):
base_config = super().get_config()
return {**base_config, "threshold": self.threshold}
让我们走一遍这段代码:
-
构造函数使用add_weight()方法创建需要在多个批次中跟踪指标状态的变量——在这种情况下,所有 Huber 损失的总和(total)和迄今为止看到的实例数量(count)。如果愿意,你也可以手动创建变量。Keras 跟踪任何设置为属性的tf.Variable(更一般地,任何“可跟踪”的对象,如层或模型)。
-
当你将这个类的实例用作函数时(就像我们用Precision对象做的那样),update_state()方法会被调用。它根据一个批次的标签和预测更新变量(以及样本权重,但在这种情况下我们忽略它们)。
-
result()方法计算并返回最终结果,在这种情况下是所有实例上的平均 Huber 指标。当你将指标用作函数时,首先调用update_state()方法,然后调用result()方法,并返回其输出。
-
我们还实现了get_config()方法,以确保threshold与模型一起保存。
-
reset_states()方法的默认实现将所有变量重置为 0.0(但如果需要,你可以覆盖它)。
注意
Keras 会无缝处理变量持久性;不需要任何操作。
当你使用简单函数定义指标时,Keras 会自动为每个批次调用它,并在每个时期期间跟踪平均值,就像我们手动做的那样。因此,我们的HuberMetric类的唯一好处是threshold将被保存。但当然,有些指标,比如精度,不能简单地在批次上进行平均:在这些情况下,除了实现流式指标之外别无选择。
现在你已经构建了一个流式指标,构建一个自定义层将会变得轻而易举!
自定义层
有时候你可能想要构建一个包含一种 TensorFlow 没有提供默认实现的奇特层的架构。或者你可能只是想要构建一个非常重复的架构,在这种架构中,一个特定的层块被重复多次,将每个块视为单个层会很方便。对于这些情况,你会想要构建一个自定义层。
有一些没有权重的层,比如tf.keras.layers.Flatten或tf.keras.layers.ReLU。如果你想创建一个没有任何权重的自定义层,最简单的方法是编写一个函数并将其包装在tf.keras.layers.Lambda层中。例如,以下层将对其输入应用指数函数:
exponential_layer = tf.keras.layers.Lambda(lambda x: tf.exp(x))
然后,这个自定义层可以像任何其他层一样使用,使用序贯 API、函数式 API 或子类 API。你也可以将它用作激活函数,或者你可以使用activation=tf.exp。指数层有时用于回归模型的输出层,当要预测的值具有非常不同的规模时(例如,0.001、10.、1,000.)。事实上,指数函数是 Keras 中的标准激活函数之一,所以你可以简单地使用activation="exponential"。
你可能会猜到,要构建一个自定义的有状态层(即带有权重的层),你需要创建tf.keras.layers.Layer类的子类。例如,以下类实现了Dense层的简化版本:
class MyDense(tf.keras.layers.Layer):
def __init__(self, units, activation=None, **kwargs):
super().__init__(**kwargs)
self.units = units
self.activation = tf.keras.activations.get(activation)
def build(self, batch_input_shape):
self.kernel = self.add_weight(
name="kernel", shape=[batch_input_shape[-1], self.units],
initializer="glorot_normal")
self.bias = self.add_weight(
name="bias", shape=[self.units], initializer="zeros")
def call(self, X):
return self.activation(X @ self.kernel + self.bias)
def get_config(self):
base_config = super().get_config()
return {**base_config, "units": self.units,
"activation": tf.keras.activations.serialize(self.activation)}
让我们来看看这段代码:
-
构造函数将所有超参数作为参数(在这个例子中是units和activation),并且重要的是它还接受一个**kwargs参数。它调用父构造函数,将kwargs传递给它:这会处理标准参数,如input_shape、trainable和name。然后它将超参数保存为属性,使用tf.keras.activations.get()函数将activation参数转换为适当的激活函数(它接受函数、标准字符串如"relu"或"swish",或者简单地None)。
-
build()方法的作用是通过为每个权重调用add_weight()方法来创建层的变量。build()方法在第一次使用该层时被调用。在那时,Keras 将知道该层输入的形状,并将其传递给build()方法,这通常是创建一些权重所必需的。例如,我们需要知道前一层中的神经元数量以创建连接权重矩阵(即"kernel"):这对应于输入的最后一个维度的大小。在build()方法的最后(仅在最后),您必须调用父类的build()方法:这告诉 Keras 该层已构建(它只是设置self.built = True)。
-
call()方法执行所需的操作。在这种情况下,我们计算输入X和层的内核的矩阵乘法,添加偏置向量,并将激活函数应用于结果,这给出了层的输出。
-
get_config()方法与以前的自定义类中的方法一样。请注意,通过调用tf.keras.activations.serialize()保存激活函数的完整配置。
现在您可以像使用任何其他层一样使用MyDense层!
注意
Keras 会自动推断输出形状,除非该层是动态的(稍后将看到)。在这种(罕见)情况下,您需要实现compute_output_shape()方法,该方法必须返回一个TensorShape对象。
要创建具有多个输入的层(例如,Concatenate),call()方法的参数应该是一个包含所有输入的元组。要创建具有多个输出的层,call()方法应该返回输出的列表。例如,以下示例玩具层接受两个输入并返回三个输出:
class MyMultiLayer(tf.keras.layers.Layer):
def call(self, X):
X1, X2 = X
return X1 + X2, X1 * X2, X1 / X2
这个层现在可以像任何其他层一样使用,但当然只能使用功能 API 和子类 API,而不能使用顺序 API(顺序 API 只接受具有一个输入和一个输出的层)。
如果您的层在训练和测试期间需要具有不同的行为(例如,如果它使用Dropout或BatchNormalization层),那么您必须在call()方法中添加一个training参数,并使用此参数来决定要执行什么操作。例如,让我们创建一个在训练期间添加高斯噪声(用于正则化)但在测试期间不执行任何操作的层(Keras 有一个执行相同操作的层,tf.keras.layers.GaussianNoise):
class MyGaussianNoise(tf.keras.layers.Layer):
def __init__(self, stddev, **kwargs):
super().__init__(**kwargs)
self.stddev = stddev
def call(self, X, training=False):
if training:
noise = tf.random.normal(tf.shape(X), stddev=self.stddev)
return X + noise
else:
return X
有了这个,您现在可以构建任何您需要的自定义层!现在让我们看看如何创建自定义模型。
自定义模型
我们已经在第十章中讨论了使用子类 API 创建自定义模型类。这很简单:子类化tf.keras.Model类,在构造函数中创建层和变量,并实现call()方法以执行您希望模型执行的操作。例如,假设我们想要构建图 12-3 中表示的模型。
![mls3 1203]()
图 12-3。自定义模型示例:一个包含跳过连接的自定义ResidualBlock层的任意模型
输入首先经过一个密集层,然后通过由两个密集层和一个加法操作组成的残差块(如您将在第十四章中看到的,残差块将其输入添加到其输出中),然后通过这个相同的残差块再进行三次,然后通过第二个残差块,最终结果通过一个密集输出层。如果这个模型看起来没有太多意义,不要担心;这只是一个示例,说明您可以轻松构建任何您想要的模型,甚至包含循环和跳过连接的模型。要实现这个模型,最好首先创建一个ResidualBlock层,因为我们将创建一对相同的块(并且可能希望在另一个模型中重用它):
class ResidualBlock(tf.keras.layers.Layer):
def __init__(self, n_layers, n_neurons, **kwargs):
super().__init__(**kwargs)
self.hidden = [tf.keras.layers.Dense(n_neurons, activation="relu",
kernel_initializer="he_normal")
for _ in range(n_layers)]
def call(self, inputs):
Z = inputs
for layer in self.hidden:
Z = layer(Z)
return inputs + Z
这个层有点特殊,因为它包含其他层。Keras 会自动处理这一点:它会自动检测hidden属性包含可跟踪对象(在这种情况下是层),因此它们的变量会自动添加到此层的变量列表中。这个类的其余部分是不言自明的。接下来,让我们使用子类 API 来定义模型本身:
class ResidualRegressor(tf.keras.Model):
def __init__(self, output_dim, **kwargs):
super().__init__(**kwargs)
self.hidden1 = tf.keras.layers.Dense(30, activation="relu",
kernel_initializer="he_normal")
self.block1 = ResidualBlock(2, 30)
self.block2 = ResidualBlock(2, 30)
self.out = tf.keras.layers.Dense(output_dim)
def call(self, inputs):
Z = self.hidden1(inputs)
for _ in range(1 + 3):
Z = self.block1(Z)
Z = self.block2(Z)
return self.out(Z)
我们在构造函数中创建层,并在call()方法中使用它们。然后可以像任何其他模型一样使用此模型(编译、拟合、评估和使用它进行预测)。如果您还希望能够使用save()方法保存模型,并使用tf.keras.models.load_model()函数加载模型,则必须在ResidualBlock类和ResidualRegressor类中实现get_config()方法(就像我们之前做的那样)。或者,您可以使用save_weights()和load_weights()方法保存和加载权重。
Model类是Layer类的子类,因此模型可以像层一样定义和使用。但是模型具有一些额外的功能,包括当然包括compile()、fit()、evaluate()和predict()方法(以及一些变体),还有get_layer()方法(可以通过名称或索引返回模型的任何层)和save()方法(以及对tf.keras.models.load_model()和tf.keras.models.clone_model()的支持)。
提示
如果模型提供的功能比层更多,为什么不将每个层都定义为模型呢?技术上您可以这样做,但通常更清晰的做法是区分模型的内部组件(即层或可重用的层块)和模型本身(即您将训练的对象)。前者应该是Layer类的子类,而后者应该是Model类的子类。
有了这些,您可以自然而简洁地构建几乎任何您在论文中找到的模型,使用顺序 API、函数 API、子类 API,甚至这些的混合。“几乎”任何模型?是的,还有一些事情我们需要看一下:首先是如何基于模型内部定义损失或指标,其次是如何构建自定义训练循环。
基于模型内部的损失和指标
我们之前定义的自定义损失和指标都是基于标签和预测(以及可选的样本权重)。有时您可能希望基于模型的其他部分(例如其隐藏层的权重或激活)定义损失。这可能对正则化目的或监视模型的某些内部方面很有用。
要基于模型内部定义自定义损失,可以根据模型的任何部分计算损失,然后将结果传递给add_loss()方法。例如,让我们构建一个由五个隐藏层堆叠加一个输出层组成的自定义回归 MLP 模型。这个自定义模型还将在最上面的隐藏层之上具有一个辅助输出。与这个辅助输出相关联的损失将被称为重建损失(参见第十七章):它是重建和输入之间的均方差差异。通过将这个重建损失添加到主要损失中,我们将鼓励模型通过隐藏层尽可能保留更多信息,即使这些信息对于回归任务本身并不直接有用。在实践中,这种损失有时会改善泛化能力(它是一种正则化损失)。还可以使用模型的add_metric()方法添加自定义指标。以下是具有自定义重建损失和相应指标的自定义模型的代码:
class ReconstructingRegressor(tf.keras.Model):
def __init__(self, output_dim, **kwargs):
super().__init__(**kwargs)
self.hidden = [tf.keras.layers.Dense(30, activation="relu",
kernel_initializer="he_normal")
for _ in range(5)]
self.out = tf.keras.layers.Dense(output_dim)
self.reconstruction_mean = tf.keras.metrics.Mean(
name="reconstruction_error")
def build(self, batch_input_shape):
n_inputs = batch_input_shape[-1]
self.reconstruct = tf.keras.layers.Dense(n_inputs)
def call(self, inputs, training=False):
Z = inputs
for layer in self.hidden:
Z = layer(Z)
reconstruction = self.reconstruct(Z)
recon_loss = tf.reduce_mean(tf.square(reconstruction - inputs))
self.add_loss(0.05 * recon_loss)
if training:
result = self.reconstruction_mean(recon_loss)
self.add_metric(result)
return self.out(Z)
让我们来看一下这段代码:
-
构造函数创建了一个具有五个密集隐藏层和一个密集输出层的 DNN。我们还创建了一个Mean流式指标,用于在训练过程中跟踪重建误差。
-
build()方法创建一个额外的密集层,用于重构模型的输入。它必须在这里创建,因为其单元数必须等于输入的数量,在调用build()方法之前这个数量是未知的。
-
call()方法通过所有五个隐藏层处理输入,然后将结果传递给重构层,该层生成重构。
-
然后call()方法计算重构损失(重构和输入之间的均方差),并使用add_loss()方法将其添加到模型的损失列表中。请注意,我们通过将重构损失乘以 0.05 来缩小重构损失(这是一个可以调整的超参数)。这确保了重构损失不会主导主要损失。
-
接下来,在训练过程中,call()方法更新重构度量并将其添加到模型中以便显示。这段代码示例实际上可以通过调用self.add_metric(recon_loss)来简化:Keras 将自动为您跟踪均值。
-
最后,call()方法将隐藏层的输出传递给输出层,并返回其输出。
在训练过程中,总损失和重构损失都会下降:
Epoch 1/5
363/363 [========] - 1s 820us/step - loss: 0.7640 - reconstruction_error: 1.2728
Epoch 2/5
363/363 [========] - 0s 809us/step - loss: 0.4584 - reconstruction_error: 0.6340
[...]
在大多数情况下,到目前为止我们讨论的一切将足以实现您想构建的任何模型,即使是具有复杂架构、损失和指标。然而,对于一些架构,如 GANs(参见第十七章),您将不得不自定义训练循环本身。在我们到达那里之前,我们必须看看如何在 TensorFlow 中自动计算梯度。
使用自动微分计算梯度
要了解如何使用自动微分(参见第十章和附录 B)自动计算梯度,让我们考虑一个简单的玩具函数:
def f(w1, w2):
return 3 * w1 ** 2 + 2 * w1 * w2
如果你懂微积分,你可以分析地找到这个函数相对于w1的偏导数是6 * w1 + 2 * w2。你也可以找到它相对于w2的偏导数是2 * w1。例如,在点(w1, w2) = (5, 3),这些偏导数分别等于 36 和 10,因此在这一点的梯度向量是(36,10)。但如果这是一个神经网络,这个函数会复杂得多,通常有数万个参数,通过手工分析找到偏导数将是一个几乎不可能的任务。一个解决方案是通过测量当你微调相应参数一点点时函数的输出如何变化来计算每个偏导数的近似值:
>>> w1, w2 = 5, 3
>>> eps = 1e-6
>>> (f(w1 + eps, w2) - f(w1, w2)) / eps
36.000003007075065
>>> (f(w1, w2 + eps) - f(w1, w2)) / eps
10.000000003174137
看起来不错!这个方法运行得相当好,而且易于实现,但它只是一个近似值,重要的是你需要至少针对每个参数调用一次f()(不是两次,因为我们可以只计算一次f(w1, w2))。每个参数至少调用一次f()使得这种方法在大型神经网络中变得难以处理。因此,我们应该使用反向模式自动微分。TensorFlow 使这变得非常简单:
w1, w2 = tf.Variable(5.), tf.Variable(3.)
with tf.GradientTape() as tape:
z = f(w1, w2)
gradients = tape.gradient(z, [w1, w2])
首先我们定义两个变量w1和w2,然后我们创建一个tf.GradientTape上下文,它将自动记录涉及变量的每个操作,最后我们要求这个磁带计算结果z相对于两个变量[w1, w2]的梯度。让我们看看 TensorFlow 计算的梯度:
>>> gradients
[<tf.Tensor: shape=(), dtype=float32, numpy=36.0>,
<tf.Tensor: shape=(), dtype=float32, numpy=10.0>]
太棒了!结果不仅准确(精度仅受浮点误差限制),而且gradient()方法只需通过记录的计算一次(按相反顺序),无论有多少变量,因此非常高效。就像魔术一样!
提示
为了节省内存,在tf.GradientTape()块中只放入严格的最小值。或者,通过在tf.GradientTape()块内创建一个with tape.stop_recording()块来暂停记录。
在调用其gradient()方法后,磁带会立即被擦除,因此如果尝试两次调用gradient(),将会收到异常:
with tf.GradientTape() as tape:
z = f(w1, w2)
dz_dw1 = tape.gradient(z, w1) # returns tensor 36.0
dz_dw2 = tape.gradient(z, w2) # raises a RuntimeError!
如果您需要多次调用gradient(),您必须使磁带持久化,并在每次完成后删除它以释放资源:
with tf.GradientTape(persistent=True) as tape:
z = f(w1, w2)
dz_dw1 = tape.gradient(z, w1) # returns tensor 36.0
dz_dw2 = tape.gradient(z, w2) # returns tensor 10.0, works fine now!
del tape
默认情况下,磁带只会跟踪涉及变量的操作,因此,如果您尝试计算z相对于除变量以外的任何东西的梯度,结果将是None:
c1, c2 = tf.constant(5.), tf.constant(3.)
with tf.GradientTape() as tape:
z = f(c1, c2)
gradients = tape.gradient(z, [c1, c2]) # returns [None, None]
但是,您可以强制磁带监视任何您喜欢的张量,记录涉及它们的每个操作。然后,您可以计算相对于这些张量的梯度,就像它们是变量一样:
with tf.GradientTape() as tape:
tape.watch(c1)
tape.watch(c2)
z = f(c1, c2)
gradients = tape.gradient(z, [c1, c2]) # returns [tensor 36., tensor 10.]
在某些情况下,这可能很有用,比如如果您想要实现一个正则化损失,惩罚激活在输入变化很小时变化很大的情况:损失将基于激活相对于输入的梯度。由于输入不是变量,您需要告诉磁带监视它们。
大多数情况下,梯度磁带用于计算单个值(通常是损失)相对于一组值(通常是模型参数)的梯度。这就是反向模式自动微分的优势所在,因为它只需要进行一次前向传递和一次反向传递就可以一次性获得所有梯度。如果尝试计算向量的梯度,例如包含多个损失的向量,那么 TensorFlow 将计算向量总和的梯度。因此,如果您需要获取各个梯度(例如,每个损失相对于模型参数的梯度),您必须调用磁带的jacobian()方法:它将为向量中的每个损失执行一次反向模式自动微分(默认情况下全部并行)。甚至可以计算二阶偏导数(Hessians,即偏导数的偏导数),但在实践中很少需要(请参阅本章笔记本的“使用自动微分计算梯度”部分以获取示例)。
在某些情况下,您可能希望阻止梯度通过神经网络的某些部分进行反向传播。为此,您必须使用tf.stop_gradient()函数。该函数在前向传递期间返回其输入(类似于tf.identity()),但在反向传播期间不允许梯度通过(它的作用类似于常数):
def f(w1, w2):
return 3 * w1 ** 2 + tf.stop_gradient(2 * w1 * w2)
with tf.GradientTape() as tape:
z = f(w1, w2) # the forward pass is not affected by stop_gradient()
gradients = tape.gradient(z, [w1, w2]) # returns [tensor 30., None]
最后,当计算梯度时,您可能偶尔会遇到一些数值问题。例如,如果在x=10^(-50)处计算平方根函数的梯度,结果将是无穷大。实际上,该点的斜率并不是无穷大,但它超过了 32 位浮点数的处理能力:
>>> x = tf.Variable(1e-50)
>>> with tf.GradientTape() as tape:
... z = tf.sqrt(x)
...
>>> tape.gradient(z, [x])
[<tf.Tensor: shape=(), dtype=float32, numpy=inf>]
为了解决这个问题,在计算平方根时,通常建议向x(例如 10^(-6))添加一个微小值。
指数函数也经常引起头痛,因为它增长非常快。例如,之前定义的my_softplus()的方式在数值上不稳定。如果计算my_softplus(100.0),您将得到无穷大而不是正确的结果(约为 100)。但是可以重写该函数以使其在数值上稳定:softplus 函数被定义为 log(1 + exp(z)),这也等于 log(1 + exp(–|z|)) + max(z, 0)(请参阅数学证明的笔记本),第二种形式的优势在于指数项不会爆炸。因此,这是my_softplus()函数的更好实现:
def my_softplus(z):
return tf.math.log(1 + tf.exp(-tf.abs(z))) + tf.maximum(0., z)
在一些罕见的情况下,一个数值稳定的函数可能仍然具有数值不稳定的梯度。在这种情况下,你将不得不告诉 TensorFlow 使用哪个方程来计算梯度,而不是让它使用自动微分。为此,你必须在定义函数时使用@tf.custom_gradient装饰器,并返回函数的通常结果以及计算梯度的函数。例如,让我们更新my_softplus()函数,使其也返回一个数值稳定的梯度函数:
@tf.custom_gradient
def my_softplus(z):
def my_softplus_gradients(grads): # grads = backprop'ed from upper layers
return grads * (1 - 1 / (1 + tf.exp(z))) # stable grads of softplus
result = tf.math.log(1 + tf.exp(-tf.abs(z))) + tf.maximum(0., z)
return result, my_softplus_gradients
如果你懂微积分(参见关于这个主题的教程笔记本),你会发现 log(1 + exp(z))的导数是 exp(z) / (1 + exp(z))。但这种形式是不稳定的:对于较大的z值,它最终会计算出无穷大除以无穷大,返回 NaN。然而,通过一点代数操作,你可以证明它也等于 1 - 1 / (1 + exp(z)),这是稳定的。my_softplus_gradients()函数使用这个方程来计算梯度。请注意,这个函数将接收到目前为止反向传播的梯度,一直到my_softplus()函数,并根据链式法则,我们必须将它们与这个函数的梯度相乘。
现在当我们计算my_softplus()函数的梯度时,即使对于较大的输入值,我们也会得到正确的结果。
恭喜!现在你可以计算任何函数的梯度(只要在计算时它是可微的),甚至在需要时阻止反向传播,并编写自己的梯度函数!这可能比你需要的灵活性更多,即使你构建自己的自定义训练循环。接下来你将看到如何做到这一点。
自定义训练循环
在某些情况下,fit()方法可能不够灵活以满足你的需求。例如,我们在第十章中讨论的Wide & Deep 论文使用了两种不同的优化器:一种用于宽路径,另一种用于深路径。由于fit()方法只使用一个优化器(在编译模型时指定的那个),实现这篇论文需要编写自己的自定义循环。
你可能也喜欢编写自定义训练循环,只是为了更有信心地确保它们确实按照你的意图执行(也许你对fit()方法的一些细节不确定)。有时候,让一切都显式化可能会感觉更安全。然而,请记住,编写自定义训练循环会使你的代码变得更长、更容易出错,并且更难维护。
提示
除非你在学习或确实需要额外的灵活性,否则应该优先使用fit()方法而不是实现自己的训练循环,特别是如果你在团队中工作。
首先,让我们构建一个简单的模型。不需要编译它,因为我们将手动处理训练循环:
l2_reg = tf.keras.regularizers.l2(0.05)
model = tf.keras.models.Sequential([
tf.keras.layers.Dense(30, activation="relu", kernel_initializer="he_normal",
kernel_regularizer=l2_reg),
tf.keras.layers.Dense(1, kernel_regularizer=l2_reg)
])
接下来,让我们创建一个小函数,从训练集中随机抽取一个批次的实例(在第十三章中,我们将讨论 tf.data API,它提供了一个更好的替代方案):
def random_batch(X, y, batch_size=32):
idx = np.random.randint(len(X), size=batch_size)
return X[idx], y[idx]
让我们还定义一个函数,用于显示训练状态,包括步数、总步数、自开始时的平均损失(我们将使用Mean指标来计算),以及其他指标:
def print_status_bar(step, total, loss, metrics=None):
metrics = " - ".join([f"{m.name}: {m.result():.4f}"
for m in [loss] + (metrics or [])])
end = "" if step < total else "\n"
print(f"\r{step}/{total} - " + metrics, end=end)
这段代码很容易理解,除非你不熟悉 Python 的字符串格式化:{m.result():.4f}将指标的结果格式化为小数点后四位的浮点数,使用\r(回车)和end=""确保状态栏始终打印在同一行上。
有了这个,让我们开始吧!首先,我们需要定义一些超参数,并选择优化器、损失函数和指标(在这个例子中只有 MAE):
n_epochs = 5
batch_size = 32
n_steps = len(X_train) // batch_size
optimizer = tf.keras.optimizers.SGD(learning_rate=0.01)
loss_fn = tf.keras.losses.mean_squared_error
mean_loss = tf.keras.metrics.Mean(name="mean_loss")
metrics = [tf.keras.metrics.MeanAbsoluteError()]
现在我们准备构建自定义循环了!
for epoch in range(1, n_epochs + 1):
print("Epoch {}/{}".format(epoch, n_epochs))
for step in range(1, n_steps + 1):
X_batch, y_batch = random_batch(X_train_scaled, y_train)
with tf.GradientTape() as tape:
y_pred = model(X_batch, training=True)
main_loss = tf.reduce_mean(loss_fn(y_batch, y_pred))
loss = tf.add_n([main_loss] + model.losses)
gradients = tape.gradient(loss, model.trainable_variables)
optimizer.apply_gradients(zip(gradients, model.trainable_variables))
mean_loss(loss)
for metric in metrics:
metric(y_batch, y_pred)
print_status_bar(step, n_steps, mean_loss, metrics)
for metric in [mean_loss] + metrics:
metric.reset_states()
这段代码中有很多内容,让我们来逐步解释一下:
-
我们创建两个嵌套循环:一个用于时期,另一个用于时期内的批次。
-
然后我们从训练集中抽取一个随机批次。
-
在 tf.GradientTape() 块内,我们对一个批次进行预测,使用模型作为一个函数,并计算损失:它等于主要损失加上其他损失(在这个模型中,每层有一个正则化损失)。由于 mean_squared_error() 函数返回每个实例的一个损失,我们使用 tf.reduce_mean() 计算批次的平均值(如果您想对每个实例应用不同的权重,这就是您应该做的地方)。正则化损失已经被减少为每个单一标量,所以我们只需要对它们求和(使用 tf.add_n(),它对相同形状和数据类型的多个张量求和)。
-
接下来,我们要求磁带计算损失相对于每个可训练变量的梯度——不是所有变量!——并将它们应用于优化器以执行梯度下降步骤。
-
然后我们更新平均损失和指标(在当前时期内),并显示状态栏。
-
在每个时期结束时,我们重置平均损失和指标的状态。
如果您想应用梯度裁剪(参见第十一章),请设置优化器的 clipnorm 或 clipvalue 超参数。如果您想对梯度应用任何其他转换,只需在调用 apply_gradients() 方法之前这样做。如果您想向模型添加权重约束(例如,在创建层时设置 kernel_constraint 或 bias_constraint),您应该更新训练循环以在 apply_gradients() 之后应用这些约束,就像这样:
for variable in model.variables:
if variable.constraint is not None:
variable.assign(variable.constraint(variable))
警告
在训练循环中调用模型时不要忘记设置 training=True,特别是如果您的模型在训练和测试期间表现不同(例如,如果它使用 BatchNormalization 或 Dropout)。如果是自定义模型,请确保将 training 参数传播到您的模型调用的层。
正如您所看到的,有很多事情需要做对,很容易出错。但好的一面是,您可以完全控制。
现在您知道如何自定义模型的任何部分¹⁵和训练算法,让我们看看如何使用 TensorFlow 的自动生成图形功能:它可以显著加快您的自定义代码,并且还将其移植到 TensorFlow 支持的任何平台(参见第十九章)。
TensorFlow 函数和图形
回到 TensorFlow 1,图形是不可避免的(伴随着复杂性),因为它们是 TensorFlow API 的核心部分。自从 TensorFlow 2(2019 年发布)以来,图形仍然存在,但不再是核心部分,而且使用起来简单得多(多得多!)。为了展示它们有多简单,让我们从一个计算其输入的立方的微不足道的函数开始:
def cube(x):
return x ** 3
我们显然可以使用 Python 值(如整数或浮点数)调用此函数,或者我们可以使用张量调用它:
>>> cube(2)
8
>>> cube(tf.constant(2.0))
<tf.Tensor: shape=(), dtype=float32, numpy=8.0>
现在,让我们使用 tf.function() 将这个 Python 函数转换为 TensorFlow 函数:
>>> tf_cube = tf.function(cube)
>>> tf_cube
<tensorflow.python.eager.def_function.Function at 0x7fbfe0c54d50>
然后,这个 TF 函数可以像原始的 Python 函数一样使用,并且将返回相同的结果(但始终作为张量):
>>> tf_cube(2)
<tf.Tensor: shape=(), dtype=int32, numpy=8>
>>> tf_cube(tf.constant(2.0))
<tf.Tensor: shape=(), dtype=float32, numpy=8.0>
在幕后,tf.function() 分析了 cube() 函数执行的计算,并生成了一个等效的计算图!正如您所看到的,这是相当轻松的(我们很快会看到这是如何工作的)。或者,我们也可以将 tf.function 用作装饰器;这实际上更常见:
@tf.function
def tf_cube(x):
return x ** 3
原始的 Python 函数仍然可以通过 TF 函数的 python_function 属性访问,以防您需要它:
>>> tf_cube.python_function(2)
8
TensorFlow 优化计算图,修剪未使用的节点,简化表达式(例如,1 + 2 将被替换为 3)等。一旦优化的图准备就绪,TF 函数将有效地执行图中的操作,按适当的顺序(并在可能时并行执行)。因此,TF 函数通常比原始 Python 函数运行得快得多,特别是如果它执行复杂计算。大多数情况下,您实际上不需要知道更多:当您想要提升 Python 函数时,只需将其转换为 TF 函数。就这样!
此外,如果在调用tf.function()时设置jit_compile=True,那么 TensorFlow 将使用加速线性代数(XLA)为您的图编译专用内核,通常融合多个操作。例如,如果您的 TF 函数调用tf.reduce_sum(a * b + c),那么没有 XLA,函数首先需要计算a * b并将结果存储在临时变量中,然后将c添加到该变量中,最后调用tf.reduce_sum()处理结果。使用 XLA,整个计算将编译为单个内核,该内核将一次性计算tf.reduce_sum(a * b + c),而不使用任何大型临时变量。这不仅速度更快,而且使用的 RAM 大大减少。
当您编写自定义损失函数、自定义指标、自定义层或任何其他自定义函数,并在 Keras 模型中使用它(就像我们在本章中一直做的那样),Keras 会自动将您的函数转换为 TF 函数——无需使用tf.function()。因此,大多数情况下,这种魔术是 100%透明的。如果您希望 Keras 使用 XLA,只需在调用compile()方法时设置jit_compile=True。简单!
提示
您可以通过在创建自定义层或自定义模型时设置dynamic=True来告诉 Keras不将您的 Python 函数转换为 TF 函数。或者,您可以在调用模型的compile()方法时设置run_eagerly=True。
默认情况下,TF 函数为每个唯一的输入形状和数据类型生成一个新图,并将其缓存以供后续调用。例如,如果您调用tf_cube(tf.constant(10)),将为形状为[]的 int32 张量生成一个图。然后,如果您调用tf_cube(tf.constant(20)),将重用相同的图。但是,如果您随后调用tf_cube(tf.constant([10, 20])),将为形状为[2]的 int32 张量生成一个新图。这就是 TF 函数处理多态性(即不同的参数类型和形状)的方式。但是,这仅适用于张量参数:如果将数值 Python 值传递给 TF 函数,则将为每个不同的值生成一个新图:例如,调用tf_cube(10)和tf_cube(20)将生成两个图。
警告
如果您多次使用不同的数值 Python 值调用 TF 函数,则将生成许多图,减慢程序速度并使用大量 RAM(您必须删除 TF 函数才能释放它)。Python 值应保留用于将具有少量唯一值的参数,例如每层神经元的数量之类的超参数。这样可以使 TensorFlow 更好地优化模型的每个变体。
AutoGraph 和跟踪
那么 TensorFlow 如何生成图呢?它首先通过分析 Python 函数的源代码来捕获所有控制流语句,比如for循环、while循环和if语句,以及break、continue和return语句。这第一步被称为AutoGraph。TensorFlow 必须分析源代码的原因是 Python 没有提供其他捕获控制流语句的方法:它提供了像__add__()和__mul__()这样的魔术方法来捕获+和*等运算符,但没有__while__()或__if__()这样的魔术方法。在分析函数代码之后,AutoGraph 会输出一个升级版本的函数,其中所有控制流语句都被适当的 TensorFlow 操作替换,比如tf.while_loop()用于循环,tf.cond()用于if语句。例如,在图 12-4 中,AutoGraph 分析了sum_squares() Python 函数的源代码,并生成了tf__sum_squares()函数。在这个函数中,for循环被替换为loop_body()函数的定义(包含原始for循环的主体),然后调用for_stmt()函数。这个调用将在计算图中构建适当的tf.while_loop()操作。
![mls3 1204]()
图 12-4. TensorFlow 如何使用 AutoGraph 和跟踪生成图
接下来,TensorFlow 调用这个“升级”函数,但不是传递参数,而是传递一个符号张量—一个没有实际值的张量,只有一个名称、一个数据类型和一个形状。例如,如果您调用sum_squares(tf.constant(10)),那么tf__sum_squares()函数将被调用,传递一个类型为 int32、形状为[]的符号张量。该函数将在图模式下运行,这意味着每个 TensorFlow 操作都会在图中添加一个节点来表示自己和其输出张量(与常规模式相反,称为急切执行或急切模式)。在图模式下,TF 操作不执行任何计算。图模式是 TensorFlow 1 中的默认模式。在图 12-4 中,您可以看到tf__sum_squares()函数被调用,其参数是一个符号张量(在这种情况下,一个形状为[]的 int32 张量),以及在跟踪期间生成的最终图。节点表示操作,箭头表示张量(生成的函数和图都被简化了)。
提示
为了查看生成的函数源代码,您可以调用tf.autograph.to_code(sum_squares.python_function)。代码并不一定要漂亮,但有时可以帮助调试。
TF 函数规则
大多数情况下,将执行 TensorFlow 操作的 Python 函数转换为 TF 函数是微不足道的:用@tf.function装饰它,或者让 Keras 为您处理。但是,有一些规则需要遵守:
-
如果调用任何外部库,包括 NumPy 甚至标准库,这个调用只会在跟踪期间运行;它不会成为图的一部分。实际上,TensorFlow 图只能包括 TensorFlow 构造(张量、操作、变量、数据集等)。因此,请确保使用tf.reduce_sum()而不是np.sum(),tf.sort()而不是内置的sorted()函数,等等(除非您真的希望代码只在跟踪期间运行)。这还有一些额外的影响:
-
如果您定义了一个 TF 函数f(*x*),它只返回np.random.rand(),那么只有在跟踪函数时才会生成一个随机数,因此f(tf.constant(2.))和f(tf.constant(3.))将返回相同的随机数,但f(tf.constant([2., 3.]))将返回一个不同的随机数。如果将np.random.rand()替换为tf.random.uniform([]),那么每次调用都会生成一个新的随机数,因为该操作将成为图的一部分。
-
如果您的非 TensorFlow 代码具有副作用(例如记录某些内容或更新 Python 计数器),那么您不应该期望每次调用 TF 函数时都会发生这些副作用,因为它们只会在函数被跟踪时发生。
-
您可以在 tf.py_function() 操作中包装任意的 Python 代码,但这样做会影响性能,因为 TensorFlow 将无法对此代码进行任何图优化。这也会降低可移植性,因为图仅在安装了正确库的平台上运行 Python 可用(和 Python 可用的平台)。
-
您可以调用其他 Python 函数或 TF 函数,但它们应该遵循相同的规则,因为 TensorFlow 将捕获它们的操作在计算图中。请注意,这些其他函数不需要用 @tf.function 装饰。
-
如果函数创建了 TensorFlow 变量(或任何其他有状态的 TensorFlow 对象,例如数据集或队列),它必须在第一次调用时才能这样做,否则您将收到异常。通常最好在 TF 函数之外创建变量(例如,在自定义层的 build() 方法中)。如果要为变量分配新值,请确保调用其 assign() 方法,而不是使用 = 运算符。
-
您的 Python 函数的源代码应该对 TensorFlow 可用。如果源代码不可用(例如,如果您在 Python shell 中定义函数,无法访问源代码,或者如果您仅将编译后的 *.pyc Python 文件部署到生产环境),则图生成过程将失败或功能有限。
-
TensorFlow 仅会捕获对张量或 tf.data.Dataset 进行迭代的 for 循环(请参见第十三章)。因此,请确保使用 for i in tf.range(*x*) 而不是 for i in range(*x*),否则循环将不会在图中被捕获。相反,它将在跟踪期间运行。(如果 for 循环旨在构建图,例如在神经网络中创建每个层,那么这可能是您想要的。)
-
一如既往,出于性能原因,您应该尽可能使用矢量化实现,而不是使用循环。
是时候总结了!在本章中,我们从 TensorFlow 的简要概述开始,然后看了 TensorFlow 的低级 API,包括张量、操作、变量和特殊数据结构。然后我们使用这些工具来自定义 Keras API 中的几乎每个组件。最后,我们看了 TF 函数如何提升性能,如何使用 AutoGraph 和跟踪生成图形,以及编写 TF 函数时应遵循的规则(如果您想进一步打开黑匣子并探索生成的图形,您将在附录 D 中找到技术细节)。
在下一章中,我们将学习如何使用 TensorFlow 高效加载和预处理数据。
练习
-
您如何用简短的句子描述 TensorFlow?它的主要特点是什么?您能否列出其他流行的深度学习库?
-
TensorFlow 是否可以替代 NumPy?它们之间的主要区别是什么?
-
tf.range(10) 和 tf.constant(np.arange(10)) 会得到相同的结果吗?
-
您能否列出 TensorFlow 中除了常规张量之外的其他六种数据结构?
-
您可以通过编写函数或子类化 tf.keras.losses.Loss 类来定义自定义损失函数。您会在什么时候使用每个选项?
-
同样,您可以在函数中定义自定义指标,也可以作为 tf.keras.metrics.Metric 的子类。您会在什么时候使用每个选项?
-
何时应该创建自定义层而不是自定义模型?
-
有哪些需要编写自定义训练循环的用例?
-
自定义 Keras 组件可以包含任意的 Python 代码吗,还是必须可转换为 TF 函数?
-
如果您希望函数可转换为 TF 函数,主要需要遵守哪些规则?
-
何时需要创建一个动态的 Keras 模型?如何做到这一点?为什么不将所有模型都设置为动态的呢?
-
实现一个执行层归一化的自定义层(我们将在第十五章中使用这种类型的层):
-
build()方法应该定义两个可训练的权重α和β,形状都是input_shape[-1:],数据类型为tf.float32。α应该初始化为 1,β初始化为 0。
-
call()方法应该计算每个实例特征的平均值μ和标准差σ。为此,您可以使用tf.nn.moments(inputs, axes=-1, keepdims=True),它返回所有实例的平均值μ和方差σ²(计算方差的平方根以获得标准差)。然后函数应该计算并返回α ⊗ (X - μ)/(σ + ε) + β,其中 ⊗ 表示逐元素乘法(*),ε是一个平滑项(一个小常数,避免除以零,例如 0.001)。
-
确保您的自定义层产生与tf.keras.layers.LayerNormalization层相同(或非常接近)的输出。
-
使用自定义训练循环训练一个模型,以处理 Fashion MNIST 数据集(参见第十章):
-
显示每个时代、迭代、平均训练损失和每个时代的平均准确率(在每次迭代更新),以及每个时代结束时的验证损失和准确率。
-
尝试使用不同的优化器以及不同的学习率来处理上层和下层。
这些练习的解决方案可以在本章笔记本的末尾找到,网址为https://homl.info/colab3。
然而,Facebook 的 PyTorch 库目前在学术界更受欢迎:比起 TensorFlow 或 Keras,更多的论文引用 PyTorch。此外,Google 的 JAX 库正在获得动力,尤其是在学术界。
TensorFlow 包括另一个名为estimators API的深度学习 API,但现在已经不推荐使用。
如果您有需要(但您可能不会),您可以使用 C++ API 编写自己的操作。
要了解更多关于 TPU 以及它们如何工作的信息,请查看https://homl.info/tpus。
tf.math.log()是一个值得注意的例外,它通常被使用,但没有tf.log()的别名,因为这可能会与日志记录混淆。
使用加权平均值不是一个好主意:如果这样做,那么具有相同权重但在不同批次中的两个实例将对训练产生不同的影响,这取决于每个批次的总权重。
{**x, [...]}语法是在 Python 3.5 中添加的,用于将字典x中的所有键/值对合并到另一个字典中。自 Python 3.9 起,您可以使用更好的x | y语法(其中x和y是两个字典)。
然而,Huber 损失很少用作度量标准——通常更喜欢使用 MAE 或 MSE。
这个类仅用于说明目的。一个更简单和更好的实现方法是只需子类化tf.keras.metrics.Mean类;请参阅本章笔记本的“流式指标”部分以获取示例。
Keras API 将此参数称为input_shape,但由于它还包括批量维度,我更喜欢将其称为batch_input_shape。
在 Keras 中,“子类 API”通常只指通过子类化创建自定义模型,尽管在本章中您已经看到,许多其他东西也可以通过子类化创建。
由于 TensorFlow 问题#46858,这种情况下调用super().build()可能会失败,除非在您阅读此内容时已修复该问题。如果没有,请将此行替换为self.built = True。
您还可以在模型内的任何层上调用add_loss(),因为模型会递归地从所有层中收集损失。
如果磁带超出范围,例如当使用它的函数返回时,Python 的垃圾收集器会为您删除它。
除了优化器之外,很少有人会自定义这些;请参阅笔记本中的“自定义优化器”部分以获取示例。
然而,在这个简单的例子中,计算图非常小,几乎没有任何优化的空间,所以tf_cube()实际上比cube()运行得慢得多。
第十三章:使用 TensorFlow 加载和预处理数据
在第二章中,您看到加载和预处理数据是任何机器学习项目的重要部分。您使用 Pandas 加载和探索(修改后的)加利福尼亚房屋数据集——该数据集存储在 CSV 文件中——并应用 Scikit-Learn 的转换器进行预处理。这些工具非常方便,您可能会经常使用它们,特别是在探索和实验数据时。
然而,在大型数据集上训练 TensorFlow 模型时,您可能更喜欢使用 TensorFlow 自己的数据加载和预处理 API,称为tf.data。它能够非常高效地加载和预处理数据,使用多线程和排队从多个文件中并行读取数据,对样本进行洗牌和分批处理等。此外,它可以实时执行所有这些操作——在 GPU 或 TPU 正在训练当前批次数据时,它会在多个 CPU 核心上加载和预处理下一批数据。
tf.data API 允许您处理无法放入内存的数据集,并充分利用硬件资源,从而加快训练速度。tf.data API 可以直接从文本文件(如 CSV 文件)、具有固定大小记录的二进制文件以及使用 TensorFlow 的 TFRecord 格式的二进制文件中读取数据。
TFRecord 是一种灵活高效的二进制格式,通常包含协议缓冲区(一种开源二进制格式)。tf.data API 还支持从 SQL 数据库中读取数据。此外,许多开源扩展可用于从各种数据源中读取数据,例如 Google 的 BigQuery 服务(请参阅https://tensorflow.org/io)。
Keras 还提供了强大而易于使用的预处理层,可以嵌入到您的模型中:这样,当您将模型部署到生产环境时,它将能够直接摄取原始数据,而无需您添加任何额外的预处理代码。这消除了训练期间使用的预处理代码与生产中使用的预处理代码之间不匹配的风险,这可能会导致训练/服务偏差。如果您将模型部署在使用不同编程语言编写的多个应用程序中,您不必多次重新实现相同的预处理代码,这也减少了不匹配的风险。
正如您将看到的,这两个 API 可以联合使用——例如,从 tf.data 提供的高效数据加载和 Keras 预处理层的便利性中受益。
在本章中,我们将首先介绍 tf.data API 和 TFRecord 格式。然后我们将探索 Keras 预处理层以及如何将它们与 tf.data API 一起使用。最后,我们将快速查看一些相关的库,您可能会发现它们在加载和预处理数据时很有用,例如 TensorFlow Datasets 和 TensorFlow Hub。所以,让我们开始吧!
tf.data API
整个 tf.data API 围绕着 tf.data.Dataset 的概念展开:这代表了一系列数据项。通常,您会使用逐渐从磁盘读取数据的数据集,但为了简单起见,让我们使用 tf.data.Dataset.from_tensor_slices() 从一个简单的数据张量创建数据集:
>>> import tensorflow as tf
>>> X = tf.range(10) # any data tensor
>>> dataset = tf.data.Dataset.from_tensor_slices(X)
>>> dataset
<TensorSliceDataset shapes: (), types: tf.int32>
from_tensor_slices() 函数接受一个张量,并创建一个 tf.data.Dataset,其中的元素是沿着第一维度的所有 X 的切片,因此这个数据集包含 10 个项目:张量 0、1、2、…、9。在这种情况下,如果我们使用 tf.data.Dataset.range(10),我们将获得相同的数据集(除了元素将是 64 位整数而不是 32 位整数)。
您可以简单地迭代数据集的项目,如下所示:
>>> for item in dataset:
... print(item)
...
tf.Tensor(0, shape=(), dtype=int32)
tf.Tensor(1, shape=(), dtype=int32)
[...]
tf.Tensor(9, shape=(), dtype=int32)
注意
tf.data API 是一个流式 API:您可以非常高效地迭代数据集的项目,但该 API 不适用于索引或切片。
数据集还可以包含张量的元组,或名称/张量对的字典,甚至是张量的嵌套元组和字典。在对元组、字典或嵌套结构进行切片时,数据集将仅切片它包含的张量,同时保留元组/字典结构。例如:
>>> X_nested = {"a": ([1, 2, 3], [4, 5, 6]), "b": [7, 8, 9]}
>>> dataset = tf.data.Dataset.from_tensor_slices(X_nested)
>>> for item in dataset:
... print(item)
...
{'a': (<tf.Tensor: [...]=1>, <tf.Tensor: [...]=4>), 'b': <tf.Tensor: [...]=7>}
{'a': (<tf.Tensor: [...]=2>, <tf.Tensor: [...]=5>), 'b': <tf.Tensor: [...]=8>}
{'a': (<tf.Tensor: [...]=3>, <tf.Tensor: [...]=6>), 'b': <tf.Tensor: [...]=9>}
链接转换
一旦您有了数据集,您可以通过调用其转换方法对其应用各种转换。每个方法都会返回一个新的数据集,因此您可以像这样链接转换(此链在图 13-1 中有示例):
>>> dataset = tf.data.Dataset.from_tensor_slices(tf.range(10))
>>> dataset = dataset.repeat(3).batch(7)
>>> for item in dataset:
... print(item)
...
tf.Tensor([0 1 2 3 4 5 6], shape=(7,), dtype=int32)
tf.Tensor([7 8 9 0 1 2 3], shape=(7,), dtype=int32)
tf.Tensor([4 5 6 7 8 9 0], shape=(7,), dtype=int32)
tf.Tensor([1 2 3 4 5 6 7], shape=(7,), dtype=int32)
tf.Tensor([8 9], shape=(2,), dtype=int32)
在这个例子中,我们首先在原始数据集上调用repeat()方法,它返回一个将原始数据集的项目重复三次的新数据集。当然,这不会将所有数据在内存中复制三次!如果您调用此方法而没有参数,新数据集将永远重复源数据集,因此迭代数据集的代码将不得不决定何时停止。
然后我们在这个新数据集上调用batch()方法,再次创建一个新数据集。这个新数据集将把前一个数据集的项目分组成七个项目一组的批次。
![mls3 1301]()
图 13-1. 链接数据集转换
最后,我们迭代这个最终数据集的项目。batch()方法必须输出一个大小为两而不是七的最终批次,但是如果您希望删除这个最终批次,使所有批次具有完全相同的大小,可以调用batch()并使用drop_remainder=True。
警告
数据集方法不会修改数据集,它们会创建新的数据集。因此,请确保保留对这些新数据集的引用(例如,使用dataset = ...),否则什么也不会发生。
您还可以通过调用map()方法来转换项目。例如,这将创建一个所有批次乘以二的新数据集:
>>> dataset = dataset.map(lambda x: x * 2) # x is a batch
>>> for item in dataset:
... print(item)
...
tf.Tensor([ 0 2 4 6 8 10 12], shape=(7,), dtype=int32)
tf.Tensor([14 16 18 0 2 4 6], shape=(7,), dtype=int32)
[...]
这个map()方法是您将调用的方法,用于对数据进行任何预处理。有时这将包括一些可能相当密集的计算,比如重塑或旋转图像,因此您通常会希望启动多个线程以加快速度。这可以通过将num_parallel_calls参数设置为要运行的线程数,或者设置为tf.data.AUTOTUNE来完成。请注意,您传递给map()方法的函数必须可以转换为 TF 函数(请参阅第十二章)。
还可以使用filter()方法简单地过滤数据集。例如,此代码创建一个仅包含总和大于 50 的批次的数据集:
>>> dataset = dataset.filter(lambda x: tf.reduce_sum(x) > 50)
>>> for item in dataset:
... print(item)
...
tf.Tensor([14 16 18 0 2 4 6], shape=(7,), dtype=int32)
tf.Tensor([ 8 10 12 14 16 18 0], shape=(7,), dtype=int32)
tf.Tensor([ 2 4 6 8 10 12 14], shape=(7,), dtype=int32)
您经常会想查看数据集中的一些项目。您可以使用take()方法来实现:
>>> for item in dataset.take(2):
... print(item)
...
tf.Tensor([14 16 18 0 2 4 6], shape=(7,), dtype=int32)
tf.Tensor([ 8 10 12 14 16 18 0], shape=(7,), dtype=int32)
数据洗牌
正如我们在第四章中讨论的,梯度下降在训练集中的实例是独立且同分布(IID)时效果最好。确保这一点的一个简单方法是对实例进行洗牌,使用shuffle()方法。它将创建一个新数据集,首先用源数据集的前几个项目填充缓冲区。然后,每当需要一个项目时,它将从缓冲区随机取出一个项目,并用源数据集中的新项目替换它,直到完全迭代源数据集。在这一点上,它将继续从缓冲区随机取出项目,直到缓冲区为空。您必须指定缓冲区大小,并且很重要的是要足够大,否则洗牌效果不会很好。¹ 只是不要超出您拥有的 RAM 量,尽管即使您有很多 RAM,也没有必要超出数据集的大小。如果您希望每次运行程序时都获得相同的随机顺序,可以提供一个随机种子。例如,以下代码创建并显示一个包含 0 到 9 的整数,重复两次,使用大小为 4 的缓冲区和随机种子 42 进行洗牌,并使用批次大小为 7 进行批处理的数据集:
>>> dataset = tf.data.Dataset.range(10).repeat(2)
>>> dataset = dataset.shuffle(buffer_size=4, seed=42).batch(7)
>>> for item in dataset:
... print(item)
...
tf.Tensor([3 0 1 6 2 5 7], shape=(7,), dtype=int64)
tf.Tensor([8 4 1 9 4 2 3], shape=(7,), dtype=int64)
tf.Tensor([7 5 0 8 9 6], shape=(6,), dtype=int64)
提示
如果在打乱的数据集上调用repeat(),默认情况下它将在每次迭代时生成一个新的顺序。这通常是个好主意,但是如果您希望在每次迭代中重复使用相同的顺序(例如,用于测试或调试),可以在调用shuffle()时设置reshuffle_each_iteration=False。
对于一个无法放入内存的大型数据集,这种简单的打乱缓冲区方法可能不够,因为缓冲区相对于数据集来说很小。一个解决方案是对源数据本身进行打乱(例如,在 Linux 上可以使用shuf命令对文本文件进行打乱)。这将显著改善打乱效果!即使源数据已经被打乱,通常也会希望再次打乱,否则每个时期将重复相同的顺序,模型可能会出现偏差(例如,由于源数据顺序中偶然存在的一些虚假模式)。为了进一步打乱实例,一个常见的方法是将源数据拆分为多个文件,然后在训练过程中以随机顺序读取它们。然而,位于同一文件中的实例仍然会相互靠近。为了避免这种情况,您可以随机选择多个文件并同时读取它们,交错它们的记录。然后在此基础上使用shuffle()方法添加一个打乱缓冲区。如果这听起来很费力,不用担心:tf.data API 可以在几行代码中实现所有这些。让我们看看您可以如何做到这一点。
从多个文件中交错行
首先,假设您已经加载了加利福尼亚房屋数据集,对其进行了打乱(除非已经打乱),并将其分为训练集、验证集和测试集。然后将每个集合分成许多 CSV 文件,每个文件看起来像这样(每行包含八个输入特征加上目标中位房价):
MedInc,HouseAge,AveRooms,AveBedrms,Popul…,AveOccup,Lat…,Long…,MedianHouseValue
3.5214,15.0,3.050,1.107,1447.0,1.606,37.63,-122.43,1.442
5.3275,5.0,6.490,0.991,3464.0,3.443,33.69,-117.39,1.687
3.1,29.0,7.542,1.592,1328.0,2.251,38.44,-122.98,1.621
[...]
假设train_filepaths包含训练文件路径列表(您还有valid_filepaths和test_filepaths):
>>> train_filepaths
['datasets/housing/my_train_00.csv', 'datasets/housing/my_train_01.csv', ...]
或者,您可以使用文件模式;例如,train_filepaths = "datasets/housing/my_train_*.csv"。现在让我们创建一个仅包含这些文件路径的数据集:
filepath_dataset = tf.data.Dataset.list_files(train_filepaths, seed=42)
默认情况下,list_files()函数返回一个打乱文件路径的数据集。一般来说这是件好事,但是如果出于某种原因不想要这样,可以设置shuffle=False。
接下来,您可以调用interleave()方法一次从五个文件中读取并交错它们的行。您还可以使用skip()方法跳过每个文件的第一行(即标题行):
n_readers = 5
dataset = filepath_dataset.interleave(
lambda filepath: tf.data.TextLineDataset(filepath).skip(1),
cycle_length=n_readers)
interleave()方法将创建一个数据集,从filepath_dataset中提取五个文件路径,对于每个文件路径,它将调用您提供的函数(在本例中是 lambda 函数)来创建一个新的数据集(在本例中是TextLineDataset)。清楚地说,在这个阶段总共会有七个数据集:文件路径数据集、交错数据集以及交错数据集内部创建的五个TextLineDataset。当您迭代交错数据集时,它将循环遍历这五个TextLineDataset,从每个数据集中逐行读取,直到所有数据集都用完。然后它将从filepath_dataset中获取下一个五个文件路径,并以相同的方式交错它们,依此类推,直到文件路径用完。为了使交错效果最佳,最好拥有相同长度的文件;否则最长文件的末尾将不会被交错。
默认情况下,interleave()不使用并行处理;它只是顺序地从每个文件中一次读取一行。如果您希望实际并行读取文件,可以将interleave()方法的num_parallel_calls参数设置为您想要的线程数(请记住,map()方法也有这个参数)。甚至可以将其设置为tf.data.AUTOTUNE,让 TensorFlow 根据可用的 CPU 动态选择正确的线程数。现在让我们看看数据集现在包含什么:
>>> for line in dataset.take(5):
... print(line)
...
tf.Tensor(b'4.5909,16.0,[...],33.63,-117.71,2.418', shape=(), dtype=string)
tf.Tensor(b'2.4792,24.0,[...],34.18,-118.38,2.0', shape=(), dtype=string)
tf.Tensor(b'4.2708,45.0,[...],37.48,-122.19,2.67', shape=(), dtype=string)
tf.Tensor(b'2.1856,41.0,[...],32.76,-117.12,1.205', shape=(), dtype=string)
tf.Tensor(b'4.1812,52.0,[...],33.73,-118.31,3.215', shape=(), dtype=string)
这些是随机选择的五个 CSV 文件的第一行(忽略标题行)。看起来不错!
注意
可以将文件路径列表传递给 TextLineDataset 构造函数:它将按顺序遍历每个文件的每一行。如果还将 num_parallel_reads 参数设置为大于一的数字,那么数据集将并行读取该数量的文件,并交错它们的行(无需调用 interleave() 方法)。但是,它不会对文件进行洗牌,也不会跳过标题行。
数据预处理
现在我们有一个返回每个实例的住房数据集,其中包含一个字节字符串的张量,我们需要进行一些预处理,包括解析字符串和缩放数据。让我们实现一些自定义函数来执行这些预处理:
X_mean, X_std = [...] # mean and scale of each feature in the training set
n_inputs = 8
def parse_csv_line(line):
defs = [0.] * n_inputs + [tf.constant([], dtype=tf.float32)]
fields = tf.io.decode_csv(line, record_defaults=defs)
return tf.stack(fields[:-1]), tf.stack(fields[-1:])
def preprocess(line):
x, y = parse_csv_line(line)
return (x - X_mean) / X_std, y
让我们逐步解释这段代码:
-
首先,代码假设我们已经预先计算了训练集中每个特征的均值和标准差。X_mean 和 X_std 只是包含八个浮点数的 1D 张量(或 NumPy 数组),每个输入特征一个。可以使用 Scikit-Learn 的 StandardScaler 在数据集的足够大的随机样本上完成这个操作。在本章的后面,我们将使用 Keras 预处理层来代替。
-
parse_csv_line() 函数接受一个 CSV 行并对其进行解析。为了帮助实现这一点,它使用 tf.io.decode_csv() 函数,该函数接受两个参数:第一个是要解析的行,第二个是包含 CSV 文件中每列的默认值的数组。这个数组(defs)告诉 TensorFlow 不仅每列的默认值是什么,还告诉它列的数量和类型。在这个例子中,我们告诉它所有特征列都是浮点数,缺失值应默认为零,但我们为最后一列(目标)提供了一个空的 tf.float32 类型的默认值数组:该数组告诉 TensorFlow 这一列包含浮点数,但没有默认值,因此如果遇到缺失值,它将引发异常。
-
tf.io.decode_csv() 函数返回一个标量张量列表(每列一个),但我们需要返回一个 1D 张量数组。因此,我们对除最后一个(目标)之外的所有张量调用 tf.stack():这将这些张量堆叠成一个 1D 数组。然后我们对目标值做同样的操作:这将使其成为一个包含单个值的 1D 张量数组,而不是标量张量。tf.io.decode_csv() 函数完成后,它将返回输入特征和目标。
-
最后,自定义的 preprocess() 函数只调用 parse_csv_line() 函数,通过减去特征均值然后除以特征标准差来缩放输入特征,并返回一个包含缩放特征和目标的元组。
让我们测试这个预处理函数:
>>> preprocess(b'4.2083,44.0,5.3232,0.9171,846.0,2.3370,37.47,-122.2,2.782')
(<tf.Tensor: shape=(8,), dtype=float32, numpy=
array([ 0.16579159, 1.216324 , -0.05204564, -0.39215982, -0.5277444 ,
-0.2633488 , 0.8543046 , -1.3072058 ], dtype=float32)>,
<tf.Tensor: shape=(1,), dtype=float32, numpy=array([2.782], dtype=float32)>)
看起来不错!preprocess() 函数可以将一个实例从字节字符串转换为一个漂亮的缩放张量,带有相应的标签。我们现在可以使用数据集的 map() 方法将 preprocess() 函数应用于数据集中的每个样本。
将所有内容放在一起
为了使代码更具重用性,让我们将迄今为止讨论的所有内容放在另一个辅助函数中;它将创建并返回一个数据集,该数据集将高效地从多个 CSV 文件中加载加利福尼亚房屋数据,对其进行预处理、洗牌和分批处理(参见图 13-2):
def csv_reader_dataset(filepaths, n_readers=5, n_read_threads=None,
n_parse_threads=5, shuffle_buffer_size=10_000, seed=42,
batch_size=32):
dataset = tf.data.Dataset.list_files(filepaths, seed=seed)
dataset = dataset.interleave(
lambda filepath: tf.data.TextLineDataset(filepath).skip(1),
cycle_length=n_readers, num_parallel_calls=n_read_threads)
dataset = dataset.map(preprocess, num_parallel_calls=n_parse_threads)
dataset = dataset.shuffle(shuffle_buffer_size, seed=seed)
return dataset.batch(batch_size).prefetch(1)
请注意,我们在最后一行使用了 prefetch() 方法。这对性能很重要,你现在会看到。
![mls3 1302]()
图 13-2. 从多个 CSV 文件加载和预处理数据
预取
通过在自定义csv_reader_dataset()函数末尾调用prefetch(1),我们正在创建一个数据集,该数据集将尽力始终领先一个批次。换句话说,当我们的训练算法在处理一个批次时,数据集将已经在并行工作,准备好获取下一个批次(例如,从磁盘读取数据并对其进行预处理)。这可以显著提高性能,如图 13-3 所示。
如果我们还确保加载和预处理是多线程的(通过在调用interleave()和map()时设置num_parallel_calls),我们可以利用多个 CPU 核心,希望准备一个数据批次的时间比在 GPU 上运行训练步骤要短:这样 GPU 将几乎 100%利用(除了从 CPU 到 GPU 的数据传输时间)[3],训练将运行得更快。
![mls3 1303]()
图 13-3。通过预取,CPU 和 GPU 并行工作:当 GPU 处理一个批次时,CPU 处理下一个批次
提示
如果您计划购买 GPU 卡,其处理能力和内存大小当然非常重要(特别是对于大型计算机视觉或自然语言处理模型,大量的 RAM 至关重要)。对于良好性能同样重要的是 GPU 的内存带宽;这是它每秒可以将多少千兆字节的数据进出其 RAM。
如果数据集足够小,可以放入内存,您可以通过使用数据集的cache()方法将其内容缓存到 RAM 来显着加快训练速度。通常应在加载和预处理数据之后,但在洗牌、重复、批处理和预取之前执行此操作。这样,每个实例只会被读取和预处理一次(而不是每个时期一次),但数据仍然会在每个时期以不同的方式洗牌,下一批数据仍然会提前准备好。
您现在已经学会了如何构建高效的输入管道,从多个文本文件加载和预处理数据。我们已经讨论了最常见的数据集方法,但还有一些您可能想看看的方法,例如concatenate()、zip()、window()、reduce()、shard()、flat_map()、apply()、unbatch()和padded_batch()。还有一些更多的类方法,例如from_generator()和from_tensors(),它们分别从 Python 生成器或张量列表创建新数据集。请查看 API 文档以获取更多详细信息。还请注意,tf.data.experimental中提供了一些实验性功能,其中许多功能可能会在未来的版本中成为核心 API 的一部分(例如,请查看CsvDataset类,以及make_csv_dataset()方法,该方法负责推断每列的类型)。
使用数据集与 Keras
现在,我们可以使用我们之前编写的自定义csv_reader_dataset()函数为训练集、验证集和测试集创建数据集。训练集将在每个时期进行洗牌(请注意,验证集和测试集也将进行洗牌,尽管我们实际上并不需要):
train_set = csv_reader_dataset(train_filepaths)
valid_set = csv_reader_dataset(valid_filepaths)
test_set = csv_reader_dataset(test_filepaths)
现在,您可以简单地使用这些数据集构建和训练 Keras 模型。当您调用模型的fit()方法时,您传递train_set而不是X_train, y_train,并传递validation_data=valid_set而不是validation_data=(X_valid, y_valid)。fit()方法将负责每个时期重复训练数据集,每个时期使用不同的随机顺序:
model = tf.keras.Sequential([...])
model.compile(loss="mse", optimizer="sgd")
model.fit(train_set, validation_data=valid_set, epochs=5)
同样,您可以将数据集传递给evaluate()和predict()方法:
test_mse = model.evaluate(test_set)
new_set = test_set.take(3) # pretend we have 3 new samples
y_pred = model.predict(new_set) # or you could just pass a NumPy array
与其他数据集不同,new_set通常不包含标签。如果包含标签,就像这里一样,Keras 会忽略它们。请注意,在所有这些情况下,您仍然可以使用 NumPy 数组而不是数据集(但当然它们需要先加载和预处理)。
如果您想构建自己的自定义训练循环(如第十二章中讨论的),您可以很自然地遍历训练集:
n_epochs = 5
for epoch in range(n_epochs):
for X_batch, y_batch in train_set:
[...] # perform one gradient descent step
实际上,甚至可以创建一个 TF 函数(参见第十二章),用于整个时期训练模型。这可以真正加快训练速度:
@tf.function
def train_one_epoch(model, optimizer, loss_fn, train_set):
for X_batch, y_batch in train_set:
with tf.GradientTape() as tape:
y_pred = model(X_batch)
main_loss = tf.reduce_mean(loss_fn(y_batch, y_pred))
loss = tf.add_n([main_loss] + model.losses)
gradients = tape.gradient(loss, model.trainable_variables)
optimizer.apply_gradients(zip(gradients, model.trainable_variables))
optimizer = tf.keras.optimizers.SGD(learning_rate=0.01)
loss_fn = tf.keras.losses.mean_squared_error
for epoch in range(n_epochs):
print("\rEpoch {}/{}".format(epoch + 1, n_epochs), end="")
train_one_epoch(model, optimizer, loss_fn, train_set)
在 Keras 中,compile()方法的steps_per_execution参数允许您定义fit()方法在每次调用用于训练的tf.function时将处理的批次数。默认值只是 1,因此如果将其设置为 50,您通常会看到显着的性能改进。但是,Keras 回调的on_batch_*()方法只会在每 50 批次时调用一次。
恭喜,您现在知道如何使用 tf.data API 构建强大的输入管道!然而,到目前为止,我们一直在使用常见、简单和方便但不是真正高效的 CSV 文件,并且不太支持大型或复杂的数据结构(如图像或音频)。因此,让我们看看如何改用 TFRecords。
提示
如果您对 CSV 文件(或者您正在使用的其他格式)感到满意,您不一定必须使用 TFRecords。俗话说,如果它没有坏,就不要修理!当训练过程中的瓶颈是加载和解析数据时,TFRecords 非常有用。
TFRecord 格式
TFRecord 格式是 TensorFlow 存储大量数据并高效读取的首选格式。它是一个非常简单的二进制格式,只包含一系列大小不同的二进制记录(每个记录由长度、用于检查长度是否损坏的 CRC 校验和、实际数据,最后是数据的 CRC 校验和组成)。您可以使用tf.io.TFRecordWriter类轻松创建 TFRecord 文件:
with tf.io.TFRecordWriter("my_data.tfrecord") as f:
f.write(b"This is the first record")
f.write(b"And this is the second record")
然后,您可以使用tf.data.TFRecordDataset来读取一个或多个 TFRecord 文件:
filepaths = ["my_data.tfrecord"]
dataset = tf.data.TFRecordDataset(filepaths)
for item in dataset:
print(item)
这将输出:
tf.Tensor(b'This is the first record', shape=(), dtype=string)
tf.Tensor(b'And this is the second record', shape=(), dtype=string)
提示
默认情况下,TFRecordDataset将逐个读取文件,但您可以使其并行读取多个文件,并通过传递文件路径列表给构造函数并将num_parallel_reads设置为大于 1 的数字来交错它们的记录。或者,您可以通过使用list_files()和interleave()来获得与我们之前读取多个 CSV 文件相同的结果。
压缩的 TFRecord 文件
有时将 TFRecord 文件压缩可能很有用,特别是如果它们需要通过网络连接加载。您可以通过设置options参数创建一个压缩的 TFRecord 文件:
options = tf.io.TFRecordOptions(compression_type="GZIP")
with tf.io.TFRecordWriter("my_compressed.tfrecord", options) as f:
f.write(b"Compress, compress, compress!")
在读取压缩的 TFRecord 文件时,您需要指定压缩类型:
dataset = tf.data.TFRecordDataset(["my_compressed.tfrecord"],
compression_type="GZIP")
协议缓冲区简介
尽管每个记录可以使用您想要的任何二进制格式,但 TFRecord 文件通常包含序列化的协议缓冲区(也称为protobufs)。这是一个在 2001 年由谷歌开发的便携式、可扩展和高效的二进制格式,并于 2008 年开源;protobufs 现在被广泛使用,特别是在grpc中,谷歌的远程过程调用系统。它们使用一个看起来像这样的简单语言进行定义:
syntax = "proto3";
message Person {
string name = 1;
int32 id = 2;
repeated string email = 3;
}
这个 protobuf 定义表示我们正在使用 protobuf 格式的第 3 版,并且指定每个Person对象(可选)可能具有一个字符串类型的name、一个 int32 类型的id,以及零个或多个字符串类型的email字段。数字1、2和3是字段标识符:它们将在每个记录的二进制表示中使用。一旦你在.proto文件中有了一个定义,你就可以编译它。这需要使用 protobuf 编译器protoc在 Python(或其他语言)中生成访问类。请注意,你通常在 TensorFlow 中使用的 protobuf 定义已经为你编译好了,并且它们的 Python 类是 TensorFlow 库的一部分,因此你不需要使用protoc。你只需要知道如何在 Python 中使用protobuf 访问类。为了说明基础知识,让我们看一个简单的示例,使用为Personprotobuf 生成的访问类(代码在注释中有解释):
>>> from person_pb2 import Person # import the generated access class
>>> person = Person(name="Al", id=123, email=["a@b.com"]) # create a Person
>>> print(person) # display the Person
name: "Al"
id: 123
email: "a@b.com"
>>> person.name # read a field
'Al'
>>> person.name = "Alice" # modify a field
>>> person.email[0] # repeated fields can be accessed like arrays
'a@b.com'
>>> person.email.append("c@d.com") # add an email address
>>> serialized = person.SerializeToString() # serialize person to a byte string
>>> serialized
b'\n\x05Alice\x10{\x1a\x07a@b.com\x1a\x07c@d.com'
>>> person2 = Person() # create a new Person
>>> person2.ParseFromString(serialized) # parse the byte string (27 bytes long)
27
>>> person == person2 # now they are equal
True
简而言之,我们导入由protoc生成的Person类,创建一个实例并对其进行操作,可视化它并读取和写入一些字段,然后使用SerializeToString()方法对其进行序列化。这是准备保存或通过网络传输的二进制数据。当读取或接收这些二进制数据时,我们可以使用ParseFromString()方法进行解析,并获得被序列化的对象的副本。
你可以将序列化的Person对象保存到 TFRecord 文件中,然后加载和解析它:一切都会正常工作。然而,ParseFromString()不是一个 TensorFlow 操作,所以你不能在 tf.data 管道中的预处理函数中使用它(除非将其包装在tf.py_function()操作中,这会使代码变慢且不太可移植,正如你在第十二章中看到的)。然而,你可以使用tf.io.decode_proto()函数,它可以解析任何你想要的 protobuf,只要你提供 protobuf 定义(请参考笔记本中的示例)。也就是说,在实践中,你通常会希望使用 TensorFlow 提供的专用解析操作的预定义 protobuf。现在让我们来看看这些预定义的 protobuf。
TensorFlow Protobufs
TFRecord 文件中通常使用的主要 protobuf 是Exampleprotobuf,它表示数据集中的一个实例。它包含一个命名特征列表,其中每个特征可以是一个字节字符串列表、一个浮点数列表或一个整数列表。以下是 protobuf 定义(来自 TensorFlow 源代码):
syntax = "proto3";
message BytesList { repeated bytes value = 1; }
message FloatList { repeated float value = 1 [packed = true]; }
message Int64List { repeated int64 value = 1 [packed = true]; }
message Feature {
oneof kind {
BytesList bytes_list = 1;
FloatList float_list = 2;
Int64List int64_list = 3;
}
};
message Features { map<string, Feature> feature = 1; };
message Example { Features features = 1; };
BytesList、FloatList和Int64List的定义足够简单明了。请注意,对于重复的数值字段,使用[packed = true]进行更有效的编码。Feature包含一个BytesList、一个FloatList或一个Int64List。一个Features(带有s)包含一个将特征名称映射到相应特征值的字典。最后,一个Example只包含一个Features对象。
注意
为什么会定义Example,因为它只包含一个Features对象?嗯,TensorFlow 的开发人员可能有一天决定向其中添加更多字段。只要新的Example定义仍然包含相同 ID 的features字段,它就是向后兼容的。这种可扩展性是 protobuf 的一个伟大特性。
这是你如何创建一个代表同一个人的tf.train.Example:
from tensorflow.train import BytesList, FloatList, Int64List
from tensorflow.train import Feature, Features, Example
person_example = Example(
features=Features(
feature={
"name": Feature(bytes_list=BytesList(value=[b"Alice"])),
"id": Feature(int64_list=Int64List(value=[123])),
"emails": Feature(bytes_list=BytesList(value=[b"a@b.com",
b"c@d.com"]))
}))
这段代码有点冗长和重复,但你可以很容易地将其包装在一个小的辅助函数中。现在我们有了一个Example protobuf,我们可以通过调用其SerializeToString()方法将其序列化,然后将生成的数据写入 TFRecord 文件。让我们假装写入五次,以假装我们有几个联系人:
with tf.io.TFRecordWriter("my_contacts.tfrecord") as f:
for _ in range(5):
f.write(person_example.SerializeToString())
通常,您会写比五个Example更多的内容!通常情况下,您会创建一个转换脚本,从当前格式(比如 CSV 文件)读取数据,为每个实例创建一个Example protobuf,将它们序列化,并保存到几个 TFRecord 文件中,最好在此过程中对它们进行洗牌。这需要一些工作,所以再次确保这确实是必要的(也许您的流水线使用 CSV 文件运行良好)。
现在我们有一个包含多个序列化Example的漂亮 TFRecord 文件,让我们尝试加载它。
加载和解析示例
为了加载序列化的Example protobufs,我们将再次使用tf.data.TFRecordDataset,并使用tf.io.parse_single_example()解析每个Example。它至少需要两个参数:包含序列化数据的字符串标量张量,以及每个特征的描述。描述是一个字典,将每个特征名称映射到tf.io.FixedLenFeature描述符,指示特征的形状、类型和默认值,或者tf.io.VarLenFeature描述符,仅指示特征列表的长度可能变化的类型(例如"emails"特征)。
以下代码定义了一个描述字典,然后创建了一个TFRecordDataset,并对其应用了一个自定义预处理函数,以解析该数据集包含的每个序列化Example protobuf:
feature_description = {
"name": tf.io.FixedLenFeature([], tf.string, default_value=""),
"id": tf.io.FixedLenFeature([], tf.int64, default_value=0),
"emails": tf.io.VarLenFeature(tf.string),
}
def parse(serialized_example):
return tf.io.parse_single_example(serialized_example, feature_description)
dataset = tf.data.TFRecordDataset(["my_contacts.tfrecord"]).map(parse)
for parsed_example in dataset:
print(parsed_example)
固定长度的特征被解析为常规张量,但变长特征被解析为稀疏张量。您可以使用tf.sparse.to_dense()将稀疏张量转换为密集张量,但在这种情况下,更简单的方法是直接访问其值:
>>> tf.sparse.to_dense(parsed_example["emails"], default_value=b"")
<tf.Tensor: [...] dtype=string, numpy=array([b'a@b.com', b'c@d.com'], [...])>
>>> parsed_example["emails"].values
<tf.Tensor: [...] dtype=string, numpy=array([b'a@b.com', b'c@d.com'], [...])>
您可以使用tf.io.parse_example()批量解析示例,而不是使用tf.io.parse_single_example()逐个解析它们:
def parse(serialized_examples):
return tf.io.parse_example(serialized_examples, feature_description)
dataset = tf.data.TFRecordDataset(["my_contacts.tfrecord"]).batch(2).map(parse)
for parsed_examples in dataset:
print(parsed_examples) # two examples at a time
最后,BytesList可以包含您想要的任何二进制数据,包括任何序列化对象。例如,您可以使用tf.io.encode_jpeg()使用 JPEG 格式对图像进行编码,并将这些二进制数据放入BytesList中。稍后,当您的代码读取 TFRecord 时,它将从解析Example开始,然后需要调用tf.io.decode_jpeg()来解析数据并获取原始图像(或者您可以使用tf.io.decode_image(),它可以解码任何 BMP、GIF、JPEG 或 PNG 图像)。您还可以通过使用tf.io.serialize_tensor()对张量进行序列化,然后将生成的字节字符串放入BytesList特征中,将任何您想要的张量存储在BytesList中。稍后,当您解析 TFRecord 时,您可以使用tf.io.parse_tensor()解析这些数据。请参阅本章的笔记本https://homl.info/colab3 ,了解在 TFRecord 文件中存储图像和张量的示例。
正如您所看到的,Example protobuf 非常灵活,因此对于大多数用例来说可能已经足够了。但是,当您处理列表列表时,可能会有些繁琐。例如,假设您想对文本文档进行分类。每个文档可以表示为一个句子列表,其中每个句子表示为一个单词列表。也许每个文档还有一个评论列表,其中每个评论表示为一个单词列表。还可能有一些上下文数据,比如文档的作者、标题和发布日期。TensorFlow 的SequenceExample protobuf 就是为这种用例而设计的。
使用 SequenceExample Protobuf 处理列表列表
这是SequenceExample protobuf 的定义:
message FeatureList { repeated Feature feature = 1; };
message FeatureLists { map<string, FeatureList> feature_list = 1; };
message SequenceExample {
Features context = 1;
FeatureLists feature_lists = 2;
};
SequenceExample包含一个Features对象用于上下文数据和一个包含一个或多个命名FeatureList对象(例如,一个名为"content"的FeatureList和另一个名为"comments"的FeatureList)的FeatureLists对象。每个FeatureList包含一个Feature对象列表,每个Feature对象可能是字节字符串列表、64 位整数列表或浮点数列表(在此示例中,每个Feature可能代表一个句子或评论,可能以单词标识符列表的形式)。构建SequenceExample、序列化它并解析它类似于构建、序列化和解析Example,但您必须使用tf.io.parse_single_sequence_example()来解析单个SequenceExample或tf.io.parse_sequence_example()来解析批处理。这两个函数返回一个包含上下文特征(作为字典)和特征列表(也作为字典)的元组。如果特征列表包含不同大小的序列(如前面的示例),您可能希望使用tf.RaggedTensor.from_sparse()将它们转换为不规则张量(请参阅完整代码的笔记本):
parsed_context, parsed_feature_lists = tf.io.parse_single_sequence_example(
serialized_sequence_example, context_feature_descriptions,
sequence_feature_descriptions)
parsed_content = tf.RaggedTensor.from_sparse(parsed_feature_lists["content"])
现在您已经知道如何使用 tf.data API、TFRecords 和 protobufs 高效存储、加载、解析和预处理数据,是时候将注意力转向 Keras 预处理层了。
Keras 预处理层
为神经网络准备数据通常需要对数值特征进行归一化、对分类特征和文本进行编码、裁剪和调整图像等。有几种选项:
-
预处理可以提前在准备训练数据文件时完成,使用您喜欢的任何工具,如 NumPy、Pandas 或 Scikit-Learn。您需要在生产中应用完全相同的预处理步骤,以确保您的生产模型接收到与训练时相似的预处理输入。
-
或者,您可以在加载数据时使用 tf.data 进行即时预处理,通过使用该数据集的map()方法对数据集的每个元素应用预处理函数,就像本章前面所做的那样。同样,您需要在生产中应用相同的预处理步骤。
-
最后一种方法是直接在模型内部包含预处理层,这样它可以在训练期间即时预处理所有输入数据,然后在生产中使用相同的预处理层。本章的其余部分将讨论这种最后一种方法。
Keras 提供了许多预处理层,您可以将其包含在模型中:它们可以应用于数值特征、分类特征、图像和文本。我们将在接下来的部分中讨论数值和分类特征,以及基本文本预处理,我们将在第十四章中涵盖图像预处理,以及在第十六章中涵盖更高级的文本预处理。
归一化层
正如我们在第十章中看到的,Keras 提供了一个Normalization层,我们可以用来标准化输入特征。我们可以在创建层时指定每个特征的均值和方差,或者更简单地在拟合模型之前将训练集传递给该层的adapt()方法,以便该层可以在训练之前自行测量特征的均值和方差:
norm_layer = tf.keras.layers.Normalization()
model = tf.keras.models.Sequential([
norm_layer,
tf.keras.layers.Dense(1)
])
model.compile(loss="mse", optimizer=tf.keras.optimizers.SGD(learning_rate=2e-3))
norm_layer.adapt(X_train) # computes the mean and variance of every feature
model.fit(X_train, y_train, validation_data=(X_valid, y_valid), epochs=5)
提示
传递给adapt()方法的数据样本必须足够大,以代表您的数据集,但不必是完整的训练集:对于Normalization层,从训练集中随机抽取的几百个实例通常足以获得特征均值和方差的良好估计。
由于我们在模型中包含了Normalization层,现在我们可以将这个模型部署到生产环境中,而不必再担心归一化的问题:模型会自动处理(参见图 13-4)。太棒了!这种方法完全消除了预处理不匹配的风险,当人们尝试为训练和生产维护不同的预处理代码,但更新其中一个并忘记更新另一个时,就会发生这种情况。生产模型最终会接收到以其不期望的方式预处理的数据。如果他们幸运的话,会得到一个明显的错误。如果不幸的话,模型的准确性会悄悄下降。
![mls3 1304]()
图 13-4。在模型中包含预处理层
直接在模型中包含预处理层很简单明了,但会减慢训练速度(在Normalization层的情况下只会稍微减慢):实际上,由于预处理是在训练过程中实时进行的,每个时期只会发生一次。我们可以通过在训练之前仅对整个训练集进行一次归一化来做得更好。为此,我们可以像使用 Scikit-Learn 的StandardScaler一样单独使用Normalization层:
norm_layer = tf.keras.layers.Normalization()
norm_layer.adapt(X_train)
X_train_scaled = norm_layer(X_train)
X_valid_scaled = norm_layer(X_valid)
现在我们可以在经过缩放的数据上训练模型,这次不需要Normalization层:
model = tf.keras.models.Sequential([tf.keras.layers.Dense(1)])
model.compile(loss="mse", optimizer=tf.keras.optimizers.SGD(learning_rate=2e-3))
model.fit(X_train_scaled, y_train, epochs=5,
validation_data=(X_valid_scaled, y_valid))
很好!这应该会加快训练速度。但是现在当我们将模型部署到生产环境时,模型不会对其输入进行预处理。为了解决这个问题,我们只需要创建一个新模型,将适应的Normalization层和刚刚训练的模型包装在一起。然后我们可以将这个最终模型部署到生产环境中,它将负责对其输入进行预处理和进行预测(参见图 13-5):
final_model = tf.keras.Sequential([norm_layer, model])
X_new = X_test[:3] # pretend we have a few new instances (unscaled)
y_pred = final_model(X_new) # preprocesses the data and makes predictions
![mls3 1305]()
图 13-5。在训练之前仅对数据进行一次预处理,然后将这些层部署到最终模型中
现在我们拥有了最佳的两种方式:训练很快,因为我们只在训练开始前对数据进行一次预处理,而最终模型可以在运行时对其输入进行预处理,而不会有任何预处理不匹配的风险。
此外,Keras 预处理层与 tf.data API 很好地配合。例如,可以将tf.data.Dataset传递给预处理层的adapt()方法。还可以使用数据集的map()方法将 Keras 预处理层应用于tf.data.Dataset。例如,以下是如何将适应的Normalization层应用于数据集中每个批次的输入特征的方法:
dataset = dataset.map(lambda X, y: (norm_layer(X), y))
最后,如果您需要比 Keras 预处理层提供的更多特性,您可以随时编写自己的 Keras 层,就像我们在第十二章中讨论的那样。例如,如果Normalization层不存在,您可以使用以下自定义层获得类似的结果:
import numpy as np
class MyNormalization(tf.keras.layers.Layer):
def adapt(self, X):
self.mean_ = np.mean(X, axis=0, keepdims=True)
self.std_ = np.std(X, axis=0, keepdims=True)
def call(self, inputs):
eps = tf.keras.backend.epsilon() # a small smoothing term
return (inputs - self.mean_) / (self.std_ + eps)
接下来,让我们看看另一个用于数值特征的 Keras 预处理层:Discretization层。
Discretization 层
Discretization层的目标是通过将值范围(称为箱)映射到类别,将数值特征转换为分类特征。这对于具有多峰分布的特征或与目标具有高度非线性关系的特征有时是有用的。例如,以下代码将数值age特征映射到三个类别,小于 18 岁,18 到 50 岁(不包括),50 岁或以上:
>>> age = tf.constant([[10.], [93.], [57.], [18.], [37.], [5.]])
>>> discretize_layer = tf.keras.layers.Discretization(bin_boundaries=[18., 50.])
>>> age_categories = discretize_layer(age)
>>> age_categories
<tf.Tensor: shape=(6, 1), dtype=int64, numpy=array([[0],[2],[2],[1],[1],[0]])>
在这个例子中,我们提供了期望的分箱边界。如果你愿意,你可以提供你想要的箱数,然后调用层的adapt()方法,让它根据值的百分位数找到合适的箱边界。例如,如果我们设置num_bins=3,那么箱边界将位于第 33 和第 66 百分位数之下的值(在这个例子中,值为 10 和 37):
>>> discretize_layer = tf.keras.layers.Discretization(num_bins=3)
>>> discretize_layer.adapt(age)
>>> age_categories = discretize_layer(age)
>>> age_categories
<tf.Tensor: shape=(6, 1), dtype=int64, numpy=array([[1],[2],[2],[1],[2],[0]])>
通常不应将诸如此类的类别标识符直接传递给神经网络,因为它们的值无法有意义地进行比较。相反,它们应该被编码,例如使用独热编码。现在让我们看看如何做到这一点。
CategoryEncoding 层
当只有少量类别(例如,少于十几个或二十个)时,独热编码通常是一个不错的选择(如第二章中讨论的)。为此,Keras 提供了CategoryEncoding层。例如,让我们对刚刚创建的age_categories特征进行独热编码:
>>> onehot_layer = tf.keras.layers.CategoryEncoding(num_tokens=3)
>>> onehot_layer(age_categories)
<tf.Tensor: shape=(6, 3), dtype=float32, numpy=
array([[0., 1., 0.],
[0., 0., 1.],
[0., 0., 1.],
[0., 1., 0.],
[0., 0., 1.],
[1., 0., 0.]], dtype=float32)>
如果尝试一次对多个分类特征进行编码(只有当它们都使用相同的类别时才有意义),CategoryEncoding类将默认执行多热编码:输出张量将包含每个输入特征中存在的每个类别的 1。例如:
>>> two_age_categories = np.array([[1, 0], [2, 2], [2, 0]])
>>> onehot_layer(two_age_categories)
<tf.Tensor: shape=(3, 3), dtype=float32, numpy=
array([[1., 1., 0.],
[0., 0., 1.],
[1., 0., 1.]], dtype=float32)>
如果您认为知道每个类别出现的次数是有用的,可以在创建CategoryEncoding层时设置output_mode="count",在这种情况下,输出张量将包含每个类别的出现次数。在前面的示例中,输出将与之前相同,只是第二行将变为[0., 0., 2.]。
请注意,多热编码和计数编码都会丢失信息,因为无法知道每个活动类别来自哪个特征。例如,[0, 1]和[1, 0]都被编码为[1., 1., 0.]。如果要避免这种情况,那么您需要分别对每个特征进行独热编码,然后连接输出。这样,[0, 1]将被编码为[1., 0., 0., 0., 1., 0.],[1, 0]将被编码为[0., 1., 0., 1., 0., 0.]。您可以通过调整类别标识符来获得相同的结果,以便它们不重叠。例如:
>>> onehot_layer = tf.keras.layers.CategoryEncoding(num_tokens=3 + 3)
>>> onehot_layer(two_age_categories + [0, 3]) # adds 3 to the second feature
<tf.Tensor: shape=(3, 6), dtype=float32, numpy=
array([[0., 1., 0., 1., 0., 0.],
[0., 0., 1., 0., 0., 1.],
[0., 0., 1., 1., 0., 0.]], dtype=float32)>
在此输出中,前三列对应于第一个特征,最后三列对应于第二个特征。这使模型能够区分这两个特征。但是,这也增加了馈送到模型的特征数量,因此需要更多的模型参数。很难事先知道单个多热编码还是每个特征的独热编码哪个效果最好:这取决于任务,您可能需要测试两种选项。
现在您可以使用独热编码或多热编码对分类整数特征进行编码。但是对于分类文本特征呢?为此,您可以使用StringLookup层。
StringLookup 层
让我们使用 Keras 的StringLookup层对cities特征进行独热编码:
>>> cities = ["Auckland", "Paris", "Paris", "San Francisco"]
>>> str_lookup_layer = tf.keras.layers.StringLookup()
>>> str_lookup_layer.adapt(cities)
>>> str_lookup_layer([["Paris"], ["Auckland"], ["Auckland"], ["Montreal"]])
<tf.Tensor: shape=(4, 1), dtype=int64, numpy=array([[1], [3], [3], [0]])>
我们首先创建一个StringLookup层,然后将其适应到数据:它发现有三个不同的类别。然后我们使用该层对一些城市进行编码。默认情况下,它们被编码为整数。未知类别被映射为 0,就像在这个例子中的“Montreal”一样。已知类别从最常见的类别开始编号,从最常见到最不常见。
方便的是,当创建StringLookup层时设置output_mode="one_hot",它将为每个类别输出一个独热向量,而不是一个整数:
>>> str_lookup_layer = tf.keras.layers.StringLookup(output_mode="one_hot")
>>> str_lookup_layer.adapt(cities)
>>> str_lookup_layer([["Paris"], ["Auckland"], ["Auckland"], ["Montreal"]])
<tf.Tensor: shape=(4, 4), dtype=float32, numpy=
array([[0., 1., 0., 0.],
[0., 0., 0., 1.],
[0., 0., 0., 1.],
[1., 0., 0., 0.]], dtype=float32)>
提示
Keras 还包括一个IntegerLookup层,其功能类似于StringLookup层,但输入为整数,而不是字符串。
如果训练集非常大,可能会方便地将层适应于训练集的随机子集。在这种情况下,层的adapt()方法可能会错过一些较少见的类别。默认情况下,它会将它们全部映射到类别 0,使它们在模型中无法区分。为了减少这种风险(同时仅在训练集的子集上调整层),您可以将num_oov_indices设置为大于 1 的整数。这是要使用的未知词汇(OOV)桶的数量:每个未知类别将使用哈希函数对 OOV 桶的数量取模,伪随机地映射到其中一个 OOV 桶。这将使模型能够区分至少一些罕见的类别。例如:
>>> str_lookup_layer = tf.keras.layers.StringLookup(num_oov_indices=5)
>>> str_lookup_layer.adapt(cities)
>>> str_lookup_layer([["Paris"], ["Auckland"], ["Foo"], ["Bar"], ["Baz"]])
<tf.Tensor: shape=(4, 1), dtype=int64, numpy=array([[5], [7], [4], [3], [4]])>
由于有五个 OOV 桶,第一个已知类别的 ID 现在是 5(“巴黎”)。但是,"Foo"、"Bar"和"Baz"是未知的,因此它们各自被映射到 OOV 桶中的一个。 "Bar"有自己的专用桶(ID 为 3),但不幸的是,"Foo"和"Baz"被映射到相同的桶中(ID 为 4),因此它们在模型中保持不可区分。这被称为哈希碰撞。减少碰撞风险的唯一方法是增加 OOV 桶的数量。但是,这也会增加总类别数,这将需要更多的 RAM 和额外的模型参数,一旦类别被独热编码。因此,不要将该数字增加得太多。
将类别伪随机映射到桶中的这种想法称为哈希技巧。Keras 提供了一个专用的层,就是Hashing层。
哈希层
对于每个类别,Keras 的Hashing层计算一个哈希值,取模于桶(或“bin”)的数量。映射完全是伪随机的,但在运行和平台之间是稳定的(即,只要桶的数量不变,相同的类别将始终被映射到相同的整数)。例如,让我们使用Hashing层来编码一些城市:
>>> hashing_layer = tf.keras.layers.Hashing(num_bins=10)
>>> hashing_layer([["Paris"], ["Tokyo"], ["Auckland"], ["Montreal"]])
<tf.Tensor: shape=(4, 1), dtype=int64, numpy=array([[0], [1], [9], [1]])>
这个层的好处是它根本不需要适应,这有时可能很有用,特别是在核外设置中(当数据集太大而无法放入内存时)。然而,我们再次遇到了哈希碰撞:“东京”和“蒙特利尔”被映射到相同的 ID,使它们在模型中无法区分。因此,通常最好坚持使用StringLookup层。
现在让我们看另一种编码类别的方法:可训练的嵌入。
使用嵌入编码分类特征
嵌入是一种高维数据(例如类别或词汇中的单词)的密集表示。如果有 50,000 个可能的类别,那么独热编码将产生一个 50,000 维的稀疏向量(即,大部分为零)。相比之下,嵌入将是一个相对较小的密集向量;例如,只有 100 个维度。
在深度学习中,嵌入通常是随机初始化的,然后通过梯度下降与其他模型参数一起训练。例如,在加利福尼亚住房数据集中,"NEAR BAY"类别最初可以由一个随机向量表示,例如[0.131, 0.890],而"NEAR OCEAN"类别可能由另一个随机向量表示,例如[0.631, 0.791]。在这个例子中,我们使用了 2D 嵌入,但维度的数量是一个可以调整的超参数。
由于这些嵌入是可训练的,它们在训练过程中会逐渐改进;由于它们在这种情况下代表的是相当相似的类别,梯度下降肯定会使它们彼此更接近,同时也会使它们远离"INLAND"类别的嵌入(参见图 13-6)。实际上,表示得越好,神经网络就越容易做出准确的预测,因此训练倾向于使嵌入成为类别的有用表示。这被称为表示学习(您将在第十七章中看到其他类型的表示学习)。
![mls3 1306]()
图 13-6。嵌入将在训练过程中逐渐改进
Keras 提供了一个Embedding层,它包装了一个嵌入矩阵:这个矩阵每行对应一个类别,每列对应一个嵌入维度。默认情况下,它是随机初始化的。要将类别 ID 转换为嵌入,Embedding层只需查找并返回对应于该类别的行。就是这样!例如,让我们用五行和 2D 嵌入初始化一个Embedding层,并用它来编码一些类别:
>>> tf.random.set_seed(42)
>>> embedding_layer = tf.keras.layers.Embedding(input_dim=5, output_dim=2)
>>> embedding_layer(np.array([2, 4, 2]))
<tf.Tensor: shape=(3, 2), dtype=float32, numpy=
array([[-0.04663396, 0.01846724],
[-0.02736737, -0.02768031],
[-0.04663396, 0.01846724]], dtype=float32)>
正如您所看到的,类别 2 被编码(两次)为 2D 向量[-0.04663396, 0.01846724],而类别 4 被编码为[-0.02736737, -0.02768031]。由于该层尚未训练,这些编码只是随机的。
警告
Embedding层是随机初始化的,因此除非使用预训练权重初始化,否则在模型之外作为独立的预处理层使用它是没有意义的。
如果要嵌入一个分类文本属性,您可以简单地将StringLookup层和Embedding层连接起来,就像这样:
>>> tf.random.set_seed(42)
>>> ocean_prox = ["<1H OCEAN", "INLAND", "NEAR OCEAN", "NEAR BAY", "ISLAND"]
>>> str_lookup_layer = tf.keras.layers.StringLookup()
>>> str_lookup_layer.adapt(ocean_prox)
>>> lookup_and_embed = tf.keras.Sequential([
... str_lookup_layer,
... tf.keras.layers.Embedding(input_dim=str_lookup_layer.vocabulary_size(),
... output_dim=2)
... ])
...
>>> lookup_and_embed(np.array([["<1H OCEAN"], ["ISLAND"], ["<1H OCEAN"]]))
<tf.Tensor: shape=(3, 2), dtype=float32, numpy=
array([[-0.01896119, 0.02223358],
[ 0.02401174, 0.03724445],
[-0.01896119, 0.02223358]], dtype=float32)>
请注意,嵌入矩阵中的行数需要等于词汇量的大小:这是总类别数,包括已知类别和 OOV 桶(默认只有一个)。StringLookup类的vocabulary_size()方法方便地返回这个数字。
提示
在这个例子中,我们使用了 2D 嵌入,但一般来说,嵌入通常有 10 到 300 个维度,取决于任务、词汇量和训练集的大小。您将需要调整这个超参数。
将所有内容放在一起,现在我们可以创建一个 Keras 模型,可以处理分类文本特征以及常规数值特征,并为每个类别(以及每个 OOV 桶)学习一个嵌入:
X_train_num, X_train_cat, y_train = [...] # load the training set
X_valid_num, X_valid_cat, y_valid = [...] # and the validation set
num_input = tf.keras.layers.Input(shape=[8], name="num")
cat_input = tf.keras.layers.Input(shape=[], dtype=tf.string, name="cat")
cat_embeddings = lookup_and_embed(cat_input)
encoded_inputs = tf.keras.layers.concatenate([num_input, cat_embeddings])
outputs = tf.keras.layers.Dense(1)(encoded_inputs)
model = tf.keras.models.Model(inputs=[num_input, cat_input], outputs=[outputs])
model.compile(loss="mse", optimizer="sgd")
history = model.fit((X_train_num, X_train_cat), y_train, epochs=5,
validation_data=((X_valid_num, X_valid_cat), y_valid))
这个模型有两个输入:num_input,每个实例包含八个数值特征,以及cat_input,每个实例包含一个分类文本输入。该模型使用我们之前创建的lookup_and_embed模型来将每个海洋接近类别编码为相应的可训练嵌入。接下来,它使用concatenate()函数将数值输入和嵌入连接起来,生成完整的编码输入,准备输入神经网络。在这一点上,我们可以添加任何类型的神经网络,但为了简单起见,我们只添加一个单一的密集输出层,然后我们创建 KerasModel,使用我们刚刚定义的输入和输出。接下来,我们编译模型并训练它,传递数值和分类输入。
正如您在第十章中看到的,由于Input层的名称是"num"和"cat",我们也可以将训练数据传递给fit()方法,使用字典而不是元组:{"num": X_train_num, "cat": X_train_cat}。或者,我们可以传递一个包含批次的tf.data.Dataset,每个批次表示为((X_batch_num, X_batch_cat), y_batch)或者({"num": X_batch_num, "cat": X_batch_cat}, y_batch)。当然,验证数据也是一样的。
注意
先进行独热编码,然后通过一个没有激活函数和偏置的Dense层等同于一个Embedding层。然而,Embedding层使用的计算量要少得多,因为它避免了许多零乘法——当嵌入矩阵的大小增长时,性能差异变得明显。Dense层的权重矩阵起到了嵌入矩阵的作用。例如,使用大小为 20 的独热向量和一个具有 10 个单元的Dense层等同于使用一个input_dim=20和output_dim=10的Embedding层。因此,在Embedding层后面的层中使用的嵌入维度不应该超过单元数。
好了,现在您已经学会了如何对分类特征进行编码,是时候将注意力转向文本预处理了。
文本预处理
Keras 为基本文本预处理提供了一个TextVectorization层。与StringLookup层类似,您必须在创建时传递一个词汇表,或者使用adapt()方法从一些训练数据中学习词汇表。让我们看一个例子:
>>> train_data = ["To be", "!(to be)", "That's the question", "Be, be, be."]
>>> text_vec_layer = tf.keras.layers.TextVectorization()
>>> text_vec_layer.adapt(train_data)
>>> text_vec_layer(["Be good!", "Question: be or be?"])
<tf.Tensor: shape=(2, 4), dtype=int64, numpy=
array([[2, 1, 0, 0],
[6, 2, 1, 2]])>
两个句子“Be good!”和“Question: be or be?”分别被编码为[2, 1, 0, 0]和[6, 2, 1, 2]。词汇表是从训练数据中的四个句子中学习的:“be” = 2,“to” = 3,等等。为构建词汇表,adapt()方法首先将训练句子转换为小写并去除标点,这就是为什么“Be”、“be”和“be?”都被编码为“be” = 2。接下来,句子被按空格拆分,生成的单词按降序频率排序,产生最终的词汇表。在编码句子时,未知单词被编码为 1。最后,由于第一个句子比第二个句子短,因此用 0 进行了填充。
提示
TextVectorization层有许多选项。例如,您可以通过设置standardize=None来保留大小写和标点,或者您可以将任何标准化函数作为standardize参数传递。您可以通过设置split=None来防止拆分,或者您可以传递自己的拆分函数。您可以设置output_sequence_length参数以确保输出序列都被裁剪或填充到所需的长度,或者您可以设置ragged=True以获得一个不规则张量而不是常规张量。请查看文档以获取更多选项。
单词 ID 必须进行编码,通常使用Embedding层:我们将在第十六章中进行这样做。或者,您可以将TextVectorization层的output_mode参数设置为"multi_hot"或"count"以获得相应的编码。然而,简单地计算单词通常不是理想的:像“to”和“the”这样的单词非常频繁,几乎没有影响,而“basketball”等更稀有的单词则更具信息量。因此,通常最好将output_mode设置为"tf_idf",它代表词频 × 逆文档频率(TF-IDF)。这类似于计数编码,但在训练数据中频繁出现的单词被降权,反之,稀有单词被升权。例如:
>>> text_vec_layer = tf.keras.layers.TextVectorization(output_mode="tf_idf")
>>> text_vec_layer.adapt(train_data)
>>> text_vec_layer(["Be good!", "Question: be or be?"])
<tf.Tensor: shape=(2, 6), dtype=float32, numpy=
array([[0.96725637, 0.6931472 , 0\. , 0\. , 0\. , 0\. ],
[0.96725637, 1.3862944 , 0\. , 0\. , 0\. , 1.0986123 ]], dtype=float32)>
TF-IDF 的变体有很多种,但TextVectorization层实现的方式是将每个单词的计数乘以一个权重,该权重等于 log(1 + d / (f + 1)),其中d是训练数据中的句子总数(也称为文档),f表示这些训练句子中包含给定单词的数量。例如,在这种情况下,训练数据中有d = 4 个句子,单词“be”出现在f = 3 个句子中。由于单词“be”在句子“Question: be or be?”中出现了两次,它被编码为 2 × log(1 + 4 / (1 + 3)) ≈ 1.3862944。单词“question”只出现一次,但由于它是一个不太常见的单词,它的编码几乎一样高:1 × log(1 + 4 / (1 + 1)) ≈ 1.0986123。请注意,对于未知单词,使用平均权重。
这种文本编码方法易于使用,并且对于基本的自然语言处理任务可以得到相当不错的结果,但它有几个重要的局限性:它只适用于用空格分隔单词的语言,它不区分同音异义词(例如“to bear”与“teddy bear”),它不提示您的模型单词“evolution”和“evolutionary”之间的关系等。如果使用多热编码、计数或 TF-IDF 编码,则单词的顺序会丢失。那么还有哪些其他选项呢?
一种选择是使用TensorFlow Text 库,它提供比TextVectorization层更高级的文本预处理功能。例如,它包括几种子词标记器,能够将文本分割成比单词更小的标记,这使得模型更容易检测到“evolution”和“evolutionary”之间有一些共同之处(有关子词标记化的更多信息,请参阅第十六章)。
另一个选择是使用预训练的语言模型组件。现在让我们来看看这个。
使用预训练语言模型组件
TensorFlow Hub 库使得在您自己的模型中重用预训练模型组件变得容易,用于文本、图像、音频等。这些模型组件称为模块。只需浏览TF Hub 存储库,找到您需要的模块,将代码示例复制到您的项目中,模块将自动下载并捆绑到一个 Keras 层中,您可以直接包含在您的模型中。模块通常包含预处理代码和预训练权重,并且通常不需要额外的训练(但当然,您的模型的其余部分肯定需要训练)。
例如,一些强大的预训练语言模型是可用的。最强大的模型非常庞大(几个千兆字节),因此为了快速示例,让我们使用nnlm-en-dim50模块,版本 2,这是一个相当基本的模块,它将原始文本作为输入并输出 50 维句子嵌入。我们将导入 TensorFlow Hub 并使用它来加载模块,然后使用该模块将两个句子编码为向量:
>>> import tensorflow_hub as hub
>>> hub_layer = hub.KerasLayer("https://tfhub.dev/google/nnlm-en-dim50/2")
>>> sentence_embeddings = hub_layer(tf.constant(["To be", "Not to be"]))
>>> sentence_embeddings.numpy().round(2)
array([[-0.25, 0.28, 0.01, 0.1 , [...] , 0.05, 0.31],
[-0.2 , 0.2 , -0.08, 0.02, [...] , -0.04, 0.15]], dtype=float32)
hub.KerasLayer层从给定的 URL 下载模块。这个特定的模块是一个句子编码器:它将字符串作为输入,并将每个字符串编码为单个向量(在本例中是一个 50 维向量)。在内部,它解析字符串(在空格上拆分单词)并使用在一个巨大的语料库上预训练的嵌入矩阵嵌入每个单词:Google News 7B 语料库(七十亿字长!)。然后计算所有单词嵌入的平均值,结果就是句子嵌入。
您只需要在您的模型中包含这个hub_layer,然后就可以开始了。请注意,这个特定的语言模型是在英语上训练的,但许多其他语言也可用,以及多语言模型。
最后,由 Hugging Face 提供的优秀开源Transformers 库也使得在您自己的模型中包含强大的语言模型组件变得容易。您可以浏览Hugging Face Hub,选择您想要的模型,并使用提供的代码示例开始。它以前只包含语言模型,但现在已扩展到包括图像模型等。
我们将在第十六章中更深入地讨论自然语言处理。现在让我们看一下 Keras 的图像预处理层。
图像预处理层
Keras 预处理 API 包括三个图像预处理层:
-
tf.keras.layers.Resizing将输入图像调整为所需大小。例如,Resizing(height=100, width=200)将每个图像调整为 100×200,可能会扭曲图像。如果设置crop_to_aspect_ratio=True,则图像将被裁剪到目标图像比例,以避免扭曲。
-
tf.keras.layers.Rescaling重新缩放像素值。例如,Rescaling(scale=2/255, offset=-1)将值从 0 → 255 缩放到-1 → 1。
-
tf.keras.layers.CenterCrop裁剪图像,保留所需高度和宽度的中心区域。
例如,让我们加载一些示例图像并对它们进行中心裁剪。为此,我们将使用 Scikit-Learn 的load_sample_images()函数;这将加载两个彩色图像,一个是中国寺庙的图像,另一个是花朵的图像(这需要 Pillow 库,如果您正在使用 Colab 或者按照安装说明进行操作,应该已经安装):
from sklearn.datasets import load_sample_images
images = load_sample_images()["images"]
crop_image_layer = tf.keras.layers.CenterCrop(height=100, width=100)
cropped_images = crop_image_layer(images)
Keras 还包括几个用于数据增强的层,如RandomCrop、RandomFlip、RandomTranslation、RandomRotation、RandomZoom、RandomHeight、RandomWidth和RandomContrast。这些层仅在训练期间激活,并随机对输入图像应用一些转换(它们的名称是不言自明的)。数据增强将人为增加训练集的大小,通常会导致性能提升,只要转换后的图像看起来像真实的(非增强的)图像。我们将在下一章更详细地介绍图像处理。
注意
在幕后,Keras 预处理层基于 TensorFlow 的低级 API。例如,Normalization层使用tf.nn.moments()来计算均值和方差,Discretization层使用tf.raw_ops.Bucketize(),CategoricalEncoding使用tf.math.bincount(),IntegerLookup和StringLookup使用tf.lookup包,Hashing和TextVectorization使用tf.strings包中的几个操作,Embedding使用tf.nn.embedding_lookup(),图像预处理层使用tf.image包中的操作。如果 Keras 预处理 API 不满足您的需求,您可能偶尔需要直接使用 TensorFlow 的低级 API。
现在让我们看看在 TensorFlow 中另一种轻松高效地加载数据的方法。
TensorFlow 数据集项目
TensorFlow 数据集(TFDS)项目使加载常见数据集变得非常容易,从小型数据集如 MNIST 或 Fashion MNIST 到像 ImageNet 这样的大型数据集(您将需要相当大的磁盘空间!)。列表包括图像数据集、文本数据集(包括翻译数据集)、音频和视频数据集、时间序列等等。您可以访问https://homl.info/tfds查看完整列表,以及每个数据集的描述。您还可以查看了解您的数据,这是一个用于探索和理解 TFDS 提供的许多数据集的工具。
TFDS 并未与 TensorFlow 捆绑在一起,但如果您在 Colab 上运行或者按照https://homl.info/install的安装说明进行安装,那么它已经安装好了。然后您可以导入tensorflow_datasets,通常为tfds,然后调用tfds.load()函数,它将下载您想要的数据(除非之前已经下载过),并将数据作为数据集字典返回(通常一个用于训练,一个用于测试,但这取决于您选择的数据集)。例如,让我们下载 MNIST:
import tensorflow_datasets as tfds
datasets = tfds.load(name="mnist")
mnist_train, mnist_test = datasets["train"], datasets["test"]
然后您可以应用任何您想要的转换(通常是洗牌、批处理和预取),然后准备训练您的模型。这里是一个简单的示例:
for batch in mnist_train.shuffle(10_000, seed=42).batch(32).prefetch(1):
images = batch["image"]
labels = batch["label"]
# [...] do something with the images and labels
提示
load()函数可以对其下载的文件进行洗牌:只需设置shuffle_files=True。但是这可能不够,最好对训练数据进行更多的洗牌。
请注意,数据集中的每个项目都是一个包含特征和标签的字典。但是 Keras 期望每个项目是一个包含两个元素的元组(再次,特征和标签)。您可以使用map()方法转换数据集,就像这样:
mnist_train = mnist_train.shuffle(buffer_size=10_000, seed=42).batch(32)
mnist_train = mnist_train.map(lambda items: (items["image"], items["label"]))
mnist_train = mnist_train.prefetch(1)
但是通过设置as_supervised=True,让load()函数为您执行此操作会更简单(显然,这仅适用于带标签的数据集)。
最后,TFDS 提供了一种方便的方法来使用split参数拆分数据。例如,如果您想要使用训练集的前 90%进行训练,剩余的 10%进行验证,整个测试集进行测试,那么您可以设置split=["train[:90%]", "train[90%:]", "test"]。load()函数将返回所有三个集合。这里是一个完整的示例,使用 TFDS 加载和拆分 MNIST 数据集,然后使用这些集合来训练和评估一个简单的 Keras 模型:
train_set, valid_set, test_set = tfds.load(
name="mnist",
split=["train[:90%]", "train[90%:]", "test"],
as_supervised=True
)
train_set = train_set.shuffle(buffer_size=10_000, seed=42).batch(32).prefetch(1)
valid_set = valid_set.batch(32).cache()
test_set = test_set.batch(32).cache()
tf.random.set_seed(42)
model = tf.keras.Sequential([
tf.keras.layers.Flatten(input_shape=(28, 28)),
tf.keras.layers.Dense(10, activation="softmax")
])
model.compile(loss="sparse_categorical_crossentropy", optimizer="nadam",
metrics=["accuracy"])
history = model.fit(train_set, validation_data=valid_set, epochs=5)
test_loss, test_accuracy = model.evaluate(test_set)
恭喜,您已经到达了这个相当技术性的章节的结尾!您可能会觉得它与神经网络的抽象美有些远,但事实是深度学习通常涉及大量数据,知道如何高效加载、解析和预处理数据是一项至关重要的技能。在下一章中,我们将看一下卷积神经网络,这是图像处理和许多其他应用中最成功的神经网络架构之一。
练习
-
为什么要使用 tf.data API?
-
将大型数据集拆分为多个文件的好处是什么?
-
在训练过程中,如何判断您的输入管道是瓶颈?您可以做些什么来解决它?
-
您可以将任何二进制数据保存到 TFRecord 文件中吗,还是只能序列化协议缓冲区?
-
为什么要费心将所有数据转换为Example协议缓冲区格式?为什么不使用自己的协议缓冲区定义?
-
在使用 TFRecords 时,何时应该激活压缩?为什么不系统地这样做?
-
数据可以在编写数据文件时直接进行预处理,或者在 tf.data 管道中进行,或者在模型内的预处理层中进行。您能列出每个选项的一些优缺点吗?
-
列举一些常见的编码分类整数特征的方法。文本呢?
-
加载时尚 MNIST 数据集(在第十章中介绍);将其分为训练集、验证集和测试集;对训练集进行洗牌;并将每个数据集保存到多个 TFRecord 文件中。每个记录应该是一个序列化的Example协议缓冲区,具有两个特征:序列化图像(使用tf.io.serialize_tensor()来序列化每个图像),和标签。然后使用 tf.data 为每个集创建一个高效的数据集。最后,使用 Keras 模型来训练这些数据集,包括一个预处理层来标准化每个输入特征。尝试使输入管道尽可能高效,使用 TensorBoard 来可视化分析数据。
-
在这个练习中,您将下载一个数据集,将其拆分,创建一个tf.data.Dataset来高效加载和预处理数据,然后构建和训练一个包含Embedding层的二元分类模型:
-
下载大型电影评论数据集,其中包含来自互联网电影数据库(IMDb)的 50,000 条电影评论。数据组织在两个目录中,train和test,每个目录包含一个pos子目录,其中包含 12,500 条正面评论,以及一个neg子目录,其中包含 12,500 条负面评论。每个评论存储在单独的文本文件中。还有其他文件和文件夹(包括预处理的词袋版本),但在这个练习中我们将忽略它们。
-
将测试集分为验证集(15,000)和测试集(10,000)。
-
使用 tf.data 为每个集创建一个高效的数据集。
-
创建一个二元分类模型,使用TextVectorization层来预处理每个评论。
-
添加一个Embedding层,并计算每个评论的平均嵌入,乘以单词数量的平方根(参见第十六章)。然后将这个重新缩放的平均嵌入传递给您模型的其余部分。
-
训练模型并查看您获得的准确性。尝试优化您的管道,使训练尽可能快。
-
使用 TFDS 更轻松地加载相同的数据集:tfds.load("imdb_reviews")。
这些练习的解决方案可以在本章笔记本的末尾找到,网址为https://homl.info/colab3。
¹ 想象一副排好序的扑克牌在您的左边:假设您只拿出前三张牌并洗牌,然后随机选取一张放在右边,将另外两张留在手中。再从左边拿一张牌,在手中的三张牌中洗牌,随机选取一张放在右边。当您像这样处理完所有的牌后,您的右边将有一副扑克牌:您认为它会被完美洗牌吗?
² 一般来说,只预取一个批次就可以了,但在某些情况下,您可能需要预取更多。或者,您可以通过将tf.data.AUTOTUNE传递给prefetch(),让 TensorFlow 自动决定。
³ 但是请查看实验性的tf.data.experimental.prefetch_to_device()函数,它可以直接将数据预取到 GPU。任何带有experimental的 TensorFlow 函数或类的名称可能会在未来版本中发生更改而没有警告。如果实验性函数失败,请尝试删除experimental一词:它可能已经移至核心 API。如果没有,请查看笔记本,我会确保其中包含最新的代码。
⁴ 由于 protobuf 对象旨在被序列化和传输,它们被称为消息。
⁵ 本章包含了您使用 TFRecords 所需了解的最基本知识。要了解更多关于 protobufs 的信息,请访问https://homl.info/protobuf。
⁶ Tomáš Mikolov 等人,“单词和短语的分布式表示及其组合性”,第 26 届国际神经信息处理系统会议论文集 2(2013):3111–3119。
⁷ Malvina Nissim 等人,“公平比耸人听闻更好:男人对医生,女人对医生”,arXiv 预印本 arXiv:1905.09866(2019)。
⁸ TensorFlow Hub 没有与 TensorFlow 捆绑在一起,但如果您在 Colab 上运行或者按照https://homl.info/install的安装说明进行安装,那么它已经安装好了。
⁹ 要精确,句子嵌入等于句子中单词嵌入的平均值乘以句子中单词数的平方根。这是为了弥补随着n增长,n个随机向量的平均值会变短的事实。
¹⁰ 对于大图像,您可以使用tf.io.encode_jpeg()。这将节省大量空间,但会损失一些图像质量。
第十四章:使用卷积神经网络进行深度计算机视觉
尽管 IBM 的 Deep Blue 超级计算机在 1996 年击败了国际象棋世界冠军加里·卡斯帕罗夫,但直到最近计算机才能可靠地执行看似微不足道的任务,比如在图片中检测小狗或识别口语。为什么这些任务对我们人类来说如此轻松?答案在于感知主要发生在我们的意识领域之外,在我们大脑中专门的视觉、听觉和其他感觉模块中。当感觉信息达到我们的意识时,它已经被赋予高级特征;例如,当你看到一张可爱小狗的图片时,你无法选择不看到小狗,不注意到它的可爱。你也无法解释如何识别一个可爱的小狗;对你来说这是显而易见的。因此,我们不能信任我们的主观经验:感知并不是微不足道的,要理解它,我们必须看看我们的感觉模块是如何工作的。
卷积神经网络(CNNs)起源于对大脑视觉皮层的研究,自上世纪 80 年代以来就被用于计算机图像识别。在过去的 10 年里,由于计算能力的增加、可用训练数据的增加,以及第十一章中介绍的用于训练深度网络的技巧,CNNs 已经成功在一些复杂的视觉任务上实现了超人类表现。它们驱动着图像搜索服务、自动驾驶汽车、自动视频分类系统等。此外,CNNs 并不局限于视觉感知:它们在许多其他任务上也取得了成功,比如语音识别和自然语言处理。然而,我们现在将专注于视觉应用。
在本章中,我们将探讨 CNNs 的起源,它们的构建模块是什么样的,以及如何使用 Keras 实现它们。然后我们将讨论一些最佳的 CNN 架构,以及其他视觉任务,包括目标检测(对图像中的多个对象进行分类并在其周围放置边界框)和语义分割(根据对象所属的类别对每个像素进行分类)。
视觉皮层的结构
David H. Hubel 和 Torsten Wiesel 在1958 年对猫进行了一系列实验¹,1959 年(以及几年后对猴子进行的实验^(3)),为视觉皮层的结构提供了关键见解(这两位作者因其工作于 1981 年获得了诺贝尔生理学或医学奖)。特别是,他们表明视觉皮层中许多神经元具有小的局部感受野,这意味着它们只对位于视觉场有限区域内的视觉刺激做出反应(见图 14-1,其中五个神经元的局部感受野由虚线圈表示)。不同神经元的感受野可能重叠,它们共同覆盖整个视觉场。
![mls3 1401]()
图 14-1. 视觉皮层中的生物神经元对视觉场中称为感受野的小区域中的特定模式做出反应;随着视觉信号通过连续的大脑模块,神经元对更大感受野中的更复杂模式做出反应
此外,作者们表明,一些神经元只对水平线的图像做出反应,而另一些只对具有不同方向的线做出反应(两个神经元可能具有相同的感受野,但对不同的线方向做出反应)。他们还注意到一些神经元具有更大的感受野,它们对更复杂的模式做出反应,这些模式是低级模式的组合。这些观察结果导致了这样一个想法,即高级神经元基于相邻低级神经元的输出(在图 14-1 中,注意到每个神经元只连接到前一层附近的神经元)。这种强大的架构能够在视觉领域的任何区域检测各种复杂的模式。
这些对视觉皮层的研究启发了 1980 年引入的neocognitron,逐渐演变成我们现在称之为卷积神经网络的东西。一个重要的里程碑是 Yann LeCun 等人在 1998 年发表的一篇论文,介绍了著名的LeNet-5架构,这种架构被银行广泛用于识别支票上的手写数字。这种架构具有一些你已经了解的构建块,如全连接层和 Sigmoid 激活函数,但它还引入了两个新的构建块:卷积层和池化层。现在让我们来看看它们。
注意
为什么不简单地使用具有全连接层的深度神经网络来进行图像识别任务呢?不幸的是,尽管这对于小图像(例如 MNIST)效果很好,但对于较大的图像来说,由于需要的参数数量巨大,它会崩溃。例如,一个 100×100 像素的图像有 10,000 个像素,如果第一层只有 1,000 个神经元(这已经严重限制了传递到下一层的信息量),这意味着总共有 1 千万个连接。而这只是第一层。CNN 通过部分连接的层和权重共享来解决这个问题。
卷积层
CNN 最重要的构建块是卷积层:第一个卷积层中的神经元不与输入图像中的每个像素相连接(就像在前几章讨论的层中那样),而只与其感受野中的像素相连接(参见图 14-2)。反过来,第二个卷积层中的每个神经元只与第一层中一个小矩形内的神经元相连接。这种架构允许网络在第一个隐藏层集中于小的低级特征,然后在下一个隐藏层中将它们组合成更大的高级特征,依此类推。这种分层结构在现实世界的图像中很常见,这也是 CNN 在图像识别方面表现出色的原因之一。
![mls3 1402]()
图 14-2。具有矩形局部感受野的 CNN 层
注意
到目前为止,我们看到的所有多层神经网络都由一长串神经元组成,我们必须在将输入图像馈送到神经网络之前将其展平为 1D。在 CNN 中,每一层都以 2D 表示,这使得更容易将神经元与其对应的输入匹配。
给定层中位于第i行,第j列的神经元连接到前一层中位于第i到第i + f[h] – 1 行,第j到第j + f[w] – 1 列的神经元的输出,其中f[h]和f[w]是感受野的高度和宽度(参见图 14-3)。为了使一层具有与前一层相同的高度和宽度,通常在输入周围添加零,如图中所示。这称为零填充。
还可以通过间隔感受野来将大输入层连接到一个较小的层,如图 14-4 所示。这显着降低了模型的计算复杂性。从一个感受野到下一个感受野的水平或垂直步长称为步幅。在图中,一个 5×7 的输入层(加上零填充)连接到一个 3×4 的层,使用 3×3 的感受野和步幅为 2(在这个例子中,步幅在两个方向上是相同的,但不一定要这样)。上层中位于第i行,第j列的神经元连接到前一层中位于第i×s[h]到第i×s[h]+f[h]–1 行,第j×s[w]到第j×s[w]+f[w]–1 列的神经元的输出,其中s[h]和s[w]是垂直和水平步幅。
![mls3 1403]()
图 14-3。层与零填充之间的连接
![mls3 1404]()
图 14-4。使用步幅为 2 降低维度
滤波器
一个神经元的权重可以表示为一个与感受野大小相同的小图像。例如,图 14-5 显示了两组可能的权重,称为滤波器(或卷积核,或只是内核)。第一个滤波器表示为一个黑色正方形,中间有一条垂直白线(它是一个 7×7 的矩阵,除了中间列全是 1,其他都是 0);使用这些权重的神经元将忽略其感受野中的所有内容,除了中间的垂直线(因为所有输入将被乘以 0,除了中间的垂直线)。第二个滤波器是一个黑色正方形,中间有一条水平白线。使用这些权重的神经元将忽略其感受野中的所有内容,除了中间的水平线。
![mls3 1405]()
图 14-5。应用两个不同的滤波器以获得两个特征图
现在,如果一个层中的所有神经元使用相同的垂直线滤波器(和相同的偏置项),并且您将输入图像输入到网络中,如图 14-5 所示(底部图像),该层将输出左上角的图像。请注意,垂直白线得到增强,而其余部分变得模糊。类似地,如果所有神经元使用相同的水平线滤波器,则会得到右上角的图像;请注意,水平白线得到增强,而其余部分被模糊化。因此,一个充满使用相同滤波器的神经元的层会输出一个特征图,突出显示激活滤波器最多的图像区域。但不用担心,您不必手动定义滤波器:相反,在训练期间,卷积层将自动学习其任务中最有用的滤波器,上面的层将学会将它们组合成更复杂的模式。
堆叠多个特征图
到目前为止,为了简单起见,我已经将每个卷积层的输出表示为一个 2D 层,但实际上,卷积层有多个滤波器(您决定有多少个),并且每个滤波器输出一个特征图,因此在 3D 中更准确地表示(请参见图 14-6)。每个特征图中的每个像素都有一个神经元,并且给定特征图中的所有神经元共享相同的参数(即相同的内核和偏置项)。不同特征图中的神经元使用不同的参数。神经元的感受野与之前描述的相同,但它跨越了前一层的所有特征图。简而言之,卷积层同时将多个可训练滤波器应用于其输入,使其能够在其输入的任何位置检测多个特征。
![mls3 1406]()
图 14-6。两个具有多个滤波器(内核)的卷积层,处理具有三个颜色通道的彩色图像;每个卷积层输出一个特征图每个滤波器
注意
所有特征图中的所有神经元共享相同的参数,这显著减少了模型中的参数数量。一旦 CNN 学会在一个位置识别模式,它就可以在任何其他位置识别它。相比之下,一旦全连接的神经网络学会在一个位置识别模式,它只能在那个特定位置识别它。
输入图像也由多个子层组成:每个颜色通道一个。如第九章中所述,通常有三个:红色、绿色和蓝色(RGB)。灰度图像只有一个通道,但有些图像可能有更多通道,例如捕捉额外光频率(如红外线)的卫星图像。
具体来说,在给定卷积层l中特征图k中第i行、第j列的神经元与前一层l – 1 中位于第i × s[h]至i × s[h] + f[h] – 1 行和第j × s[w]至j × s[w] + f[w] – 1 列的神经元的输出相连,跨所有特征图(在第l – 1 层)。请注意,在同一层中,位于相同行i和列j但在不同特征图中的所有神经元与前一层中相同位置的神经元的输出相连。
方程 14-1 总结了前面的解释,用一个大数学方程表示:它展示了如何计算卷积层中给定神经元的输出。由于所有不同的索引,它看起来有点丑陋,但它的作用只是计算所有输入的加权和,再加上偏置项。
方程 14-1。计算卷积层中神经元的输出
在这个方程中:
-
z[i,] [j,] [k] 是位于卷积层(第l层)特征图k中第i行、第j列的神经元的输出。
-
如前所述,s[h] 和 s[w] 是垂直和水平步幅,f[h] 和 f[w] 是感受野的高度和宽度,f[n′] 是前一层(第l – 1 层)中特征图的数量。
-
x[i′,] [j′,] [k′] 是位于第l – 1 层,第i′行、第j′列、特征图k′(或通道k′,如果前一层是输入层)的神经元的输出。
-
b[k] 是特征图k(在第l层)的偏置项。您可以将其视为微调特征图k的整体亮度的旋钮。
-
w[u,] [v,] [k′,] [k]是层l中特征图k中的任何神经元与其输入之间的连接权重,该输入位于行u、列v(相对于神经元的感受野),以及特征图k′。
让我们看看如何使用 Keras 创建和使用卷积层。
使用 Keras 实现卷积层
首先,让我们加载和预处理一些样本图像,使用 Scikit-Learn 的load_sample_image()函数和 Keras 的CenterCrop和Rescaling层(这些都是在第十三章中介绍的):
from sklearn.datasets import load_sample_images
import tensorflow as tf
images = load_sample_images()["images"]
images = tf.keras.layers.CenterCrop(height=70, width=120)(images)
images = tf.keras.layers.Rescaling(scale=1 / 255)(images)
让我们看一下images张量的形状:
>>> images.shape
TensorShape([2, 70, 120, 3])
哎呀,这是一个 4D 张量;我们以前从未见过这个!所有这些维度是什么意思?嗯,有两个样本图像,这解释了第一个维度。然后每个图像是 70×120,因为这是我们在创建CenterCrop层时指定的大小(原始图像是 427×640)。这解释了第二和第三维度。最后,每个像素在每个颜色通道上保存一个值,有三个颜色通道——红色、绿色和蓝色,这解释了最后一个维度。
现在让我们创建一个 2D 卷积层,并将这些图像输入其中,看看输出是什么。为此,Keras 提供了一个Convolution2D层,别名为Conv2D。在幕后,这个层依赖于 TensorFlow 的tf.nn.conv2d()操作。让我们创建一个具有 32 个滤波器的卷积层,每个滤波器大小为 7×7(使用kernel_size=7,相当于使用kernel_size=(7 , 7)),并将这个层应用于我们的两个图像的小批量:
conv_layer = tf.keras.layers.Conv2D(filters=32, kernel_size=7)
fmaps = conv_layer(images)
注意
当我们谈论 2D 卷积层时,“2D”指的是空间维度(高度和宽度),但正如你所看到的,该层接受 4D 输入:正如我们所看到的,另外两个维度是批量大小(第一个维度)和通道数(最后一个维度)。
现在让我们看一下输出的形状:
>>> fmaps.shape
TensorShape([2, 64, 114, 32])
输出形状与输入形状类似,有两个主要区别。首先,有 32 个通道而不是 3 个。这是因为我们设置了filters=32,所以我们得到 32 个输出特征图:在每个位置的红色、绿色和蓝色的强度代替,我们现在有每个位置的每个特征的强度。其次,高度和宽度都减小了 6 个像素。这是因为Conv2D层默认不使用任何零填充,这意味着我们在输出特征图的两侧丢失了一些像素,取决于滤波器的大小。在这种情况下,由于卷积核大小为 7,我们水平和垂直各丢失 6 个像素(即每侧 3 个像素)。
警告
默认选项令人惊讶地被命名为padding="valid",实际上意味着根本没有零填充!这个名称来自于这样一个事实,即在这种情况下,每个神经元的感受野严格位于输入内部的有效位置(不会超出边界)。这不是 Keras 的命名怪癖:每个人都使用这种奇怪的命名法。
如果我们设置padding="same",那么输入将在所有侧面填充足够的零,以确保输出特征图最终与输入具有相同大小(因此这个选项的名称):
>>> conv_layer = tf.keras.layers.Conv2D(filters=32, kernel_size=7,
... padding="same")
...
>>> fmaps = conv_layer(images)
>>> fmaps.shape
TensorShape([2, 70, 120, 32])
这两种填充选项在图 14-7 中有所说明。为简单起见,这里只显示了水平维度,但当然相同的逻辑也适用于垂直维度。
如果步幅大于 1(在任何方向上),那么输出大小将不等于输入大小,即使padding="same"。例如,如果设置strides=2(或等效地strides=(2, 2)),那么输出特征图将是 35×60:垂直和水平方向都减半。图 14-8 展示了当strides=2时会发生什么,两种填充选项都有。
![mls3 1407]()
图 14-7。当strides=1时的两种填充选项
![mls3 1408]()
图 14-8。当步长大于 1 时,即使使用"same"填充(和"valid"填充可能会忽略一些输入),输出也会小得多
如果您感兴趣,这是输出大小是如何计算的:
-
当padding="valid"时,如果输入的宽度为i[h],那么输出宽度等于(i[h] - f[h] + s[h]) / s[h],向下取整。请记住f[h]是卷积核的宽度,s[h]是水平步长。除法中的余数对应于输入图像右侧被忽略的列。同样的逻辑也可以用来计算输出高度,以及图像底部被忽略的行。
-
当padding="same"时,输出宽度等于i[h] / s[h],向上取整。为了实现这一点,在输入图像的左右两侧填充适当数量的零列(如果可能的话,数量相等,或者在右侧多一个)。假设输出宽度为o[w],那么填充的零列数为(o[w] - 1) × s[h] + f[h] - i[h]。同样的逻辑也可以用来计算输出高度和填充行数。
现在让我们来看一下层的权重(在方程 14-1 中被标记为w[u,] [v,] [k',] [k]和b[k])。就像Dense层一样,Conv2D层保存所有层的权重,包括卷积核和偏置。卷积核是随机初始化的,而偏置初始化为零。这些权重可以通过weights属性作为 TF 变量访问,也可以通过get_weights()方法作为 NumPy 数组访问:
>>> kernels, biases = conv_layer.get_weights()
>>> kernels.shape
(7, 7, 3, 32)
>>> biases.shape
(32,)
kernels数组是 4D 的,其形状为[kernel_height, kernel_width, input_channels, output_channels]。biases数组是 1D 的,形状为[output_channels]。输出通道的数量等于输出特征图的数量,也等于滤波器的数量。
最重要的是,需要注意输入图像的高度和宽度不会出现在卷积核的形状中:这是因为输出特征图中的所有神经元共享相同的权重,正如之前解释的那样。这意味着您可以将任何大小的图像馈送到这一层,只要它们至少与卷积核一样大,并且具有正确数量的通道(在这种情况下为三个)。
最后,通常情况下,您会希望在创建Conv2D层时指定一个激活函数(如 ReLU),并指定相应的内核初始化器(如 He 初始化)。这与Dense层的原因相同:卷积层执行线性操作,因此如果您堆叠多个卷积层而没有任何激活函数,它们都等同于单个卷积层,它们将无法学习到真正复杂的内容。
正如您所看到的,卷积层有很多超参数:filters、kernel_size、padding、strides、activation、kernel_initializer等。通常情况下,您可以使用交叉验证来找到正确的超参数值,但这是非常耗时的。我们将在本章后面讨论常见的 CNN 架构,以便让您了解在实践中哪些超参数值效果最好。
内存需求
CNN 的另一个挑战是卷积层需要大量的 RAM。这在训练过程中尤为明显,因为反向传播的反向传递需要在前向传递期间计算的所有中间值。
例如,考虑一个具有 200 个 5×5 滤波器的卷积层,步幅为 1,使用"same"填充。如果输入是一个 150×100 的 RGB 图像(三个通道),那么参数数量为(5×5×3+1)×200=15,200(+1 对应于偏置项),与全连接层相比相当小。然而,这 200 个特征图中的每一个包含 150×100 个神经元,每个神经元都需要计算其 5×5×3=75 个输入的加权和:总共有 2.25 亿次浮点乘法。虽然不像全连接层那么糟糕,但仍然相当计算密集。此外,如果使用 32 位浮点数表示特征图,那么卷积层的输出将占用 200×150×100×32=9600 万位(12 MB)的 RAM。而这只是一个实例的情况——如果一个训练批次包含 100 个实例,那么这一层将使用 1.2 GB 的 RAM!
在推断(即对新实例进行预测时),一个层占用的 RAM 可以在计算下一层后立即释放,因此你只需要两个连续层所需的 RAM。但在训练期间,前向传播期间计算的所有内容都需要保留以进行反向传播,因此所需的 RAM 量至少是所有层所需 RAM 的总量。
提示
如果由于内存不足错误而导致训练崩溃,你可以尝试减小小批量大小。或者,你可以尝试使用步幅减少维度,去掉一些层,使用 16 位浮点数代替 32 位浮点数,或者将 CNN 分布在多个设备上(你将在第十九章中看到如何做)。
现在让我们来看看 CNN 的第二个常见构建块:池化层。
池化层
一旦你理解了卷积层的工作原理,池化层就很容易理解了。它们的目标是对输入图像进行子采样(即缩小),以减少计算负载、内存使用和参数数量(从而限制过拟合的风险)。
就像在卷积层中一样,池化层中的每个神经元连接到前一层中有限数量的神经元的输出,这些神经元位于一个小的矩形感受野内。你必须像以前一样定义它的大小、步幅和填充类型。然而,池化神经元没有权重;它所做的只是使用聚合函数(如最大值或平均值)聚合输入。图 14-9 展示了最大池化层,这是最常见的池化层类型。在这个例子中,我们使用了一个 2×2 的池化核,步幅为 2,没有填充。在图 14-9 中的左下角感受野中,输入值为 1、5、3、2,因此只有最大值 5 传播到下一层。由于步幅为 2,输出图像的高度和宽度都是输入图像的一半(向下取整,因为我们没有使用填充)。
![mls3 1409]()
图 14-9。最大池化层(2×2 池化核,步幅 2,无填充)
注意
池化层通常独立地处理每个输入通道,因此输出深度(即通道数)与输入深度相同。
除了减少计算、内存使用和参数数量之外,最大池化层还引入了一定程度的不变性,如图 14-10 所示。在这里,我们假设亮像素的值低于暗像素的值,并考虑三个图像(A、B、C)通过一个 2×2 内核和步幅 2 的最大池化层。图像 B 和 C 与图像 A 相同,但向右移动了一个和两个像素。正如您所看到的,图像 A 和 B 的最大池化层的输出是相同的。这就是平移不变性的含义。对于图像 C,输出是不同的:向右移动一个像素(但仍然有 50%的不变性)。通过在 CNN 中的几层之间插入一个最大池化层,可以在更大的尺度上获得一定程度的平移不变性。此外,最大池化还提供了一定程度的旋转不变性和轻微的尺度不变性。这种不变性(即使有限)在预测不应该依赖这些细节的情况下可能是有用的,比如在分类任务中。
然而,最大池化也有一些缺点。显然,它非常破坏性:即使使用一个微小的 2×2 内核和步幅为 2,输出在两个方向上都会变小两倍(因此其面积会变小四倍),简单地丢弃了输入值的 75%。在某些应用中,不变性并不理想。以语义分割为例(根据像素所属的对象对图像中的每个像素进行分类的任务,我们将在本章后面探讨):显然,如果输入图像向右平移一个像素,输出也应该向右平移一个像素。在这种情况下的目标是等变性,而不是不变性:对输入的微小变化应导致输出的相应微小变化。
![mls3 1410]()
图 14-10。对小平移的不变性
使用 Keras 实现池化层
以下代码创建了一个MaxPooling2D层,别名为MaxPool2D,使用一个 2×2 内核。步幅默认为内核大小,因此此层使用步幅为 2(水平和垂直)。默认情况下,它使用"valid"填充(即根本不填充):
max_pool = tf.keras.layers.MaxPool2D(pool_size=2)
要创建一个平均池化层,只需使用AveragePooling2D,别名为AvgPool2D,而不是MaxPool2D。正如您所期望的那样,它的工作方式与最大池化层完全相同,只是计算均值而不是最大值。平均池化层曾经非常流行,但现在人们大多使用最大池化层,因为它们通常表现更好。这可能看起来令人惊讶,因为计算均值通常比计算最大值丢失的信息更少。但另一方面,最大池化仅保留最强的特征,摆脱了所有无意义的特征,因此下一层得到了一个更干净的信号来处理。此外,最大池化比平均池化提供更强的平移不变性,并且需要稍少的计算。
请注意,最大池化和平均池化可以沿深度维度而不是空间维度执行,尽管这不太常见。这可以让 CNN 学习对各种特征具有不变性。例如,它可以学习多个滤波器,每个滤波器检测相同模式的不同旋转(例如手写数字;参见图 14-11),深度最大池化层将确保输出不管旋转如何都是相同的。CNN 也可以学习对任何东西具有不变性:厚度、亮度、倾斜、颜色等等。
![mls3 1411]()
图 14-11。深度最大池化可以帮助 CNN 学习旋转不变性(在这种情况下)
Keras 不包括深度最大池化层,但实现一个自定义层并不太困难:
class DepthPool(tf.keras.layers.Layer):
def __init__(self, pool_size=2, **kwargs):
super().__init__(**kwargs)
self.pool_size = pool_size
def call(self, inputs):
shape = tf.shape(inputs) # shape[-1] is the number of channels
groups = shape[-1] // self.pool_size # number of channel groups
new_shape = tf.concat([shape[:-1], [groups, self.pool_size]], axis=0)
return tf.reduce_max(tf.reshape(inputs, new_shape), axis=-1)
这一层将其输入重塑为所需大小的通道组(pool_size),然后使用tf.reduce_max()来计算每个组的最大值。这种实现假定步幅等于池大小,这通常是你想要的。或者,您可以使用 TensorFlow 的tf.nn.max_pool()操作,并在Lambda层中包装以在 Keras 模型中使用它,但遗憾的是,此操作不实现 GPU 的深度池化,只实现 CPU 的深度池化。
在现代架构中经常看到的最后一种类型的池化层是全局平均池化层。它的工作方式非常不同:它只是计算每个整个特征图的平均值(就像使用与输入具有相同空间维度的池化核的平均池化层)。这意味着它只输出每个特征图和每个实例的一个数字。尽管这当然是极其破坏性的(大部分特征图中的信息都丢失了),但它可以在输出层之前非常有用,稍后您将在本章中看到。要创建这样的层,只需使用GlobalAveragePooling2D类,别名GlobalAvgPool2D:
global_avg_pool = tf.keras.layers.GlobalAvgPool2D()
这等同于以下Lambda层,它计算空间维度(高度和宽度)上的平均值:
global_avg_pool = tf.keras.layers.Lambda(
lambda X: tf.reduce_mean(X, axis=[1, 2]))
例如,如果我们将这一层应用于输入图像,我们将得到每个图像的红色、绿色和蓝色的平均强度:
>>> global_avg_pool(images)
<tf.Tensor: shape=(2, 3), dtype=float32, numpy=
array([[0.64338624, 0.5971759 , 0.5824972 ],
[0.76306933, 0.26011038, 0.10849128]], dtype=float32)>
现在您知道如何创建卷积神经网络的所有构建模块了。让我们看看如何组装它们。
CNN 架构
典型的 CNN 架构堆叠了几个卷积层(每个通常后面跟着一个 ReLU 层),然后是一个池化层,然后又是几个卷积层(+ReLU),然后是另一个池化层,依此类推。随着图像通过网络的传递,图像变得越来越小,但也通常变得越来越深(即具有更多的特征图),这要归功于卷积层(参见图 14-12)。在堆栈的顶部,添加了一个常规的前馈神经网络,由几个全连接层(+ReLUs)组成,最后一层输出预测(例如,一个 softmax 层,输出估计的类别概率)。
![mls3 1412]()
图 14-12. 典型的 CNN 架构
提示
一个常见的错误是使用太大的卷积核。例如,不要使用一个 5×5 的卷积层,而是堆叠两个 3×3 的卷积层:这将使用更少的参数,需要更少的计算,并且通常表现更好。一个例外是第一个卷积层:它通常可以有一个大的卷积核(例如 5×5),通常具有 2 或更大的步幅。这将减少图像的空间维度,而不会丢失太多信息,而且由于输入图像通常只有三个通道,因此成本不会太高。
这是如何实现一个基本的 CNN 来处理时尚 MNIST 数据集的(在第十章介绍):
from functools import partial
DefaultConv2D = partial(tf.keras.layers.Conv2D, kernel_size=3, padding="same",
activation="relu", kernel_initializer="he_normal")
model = tf.keras.Sequential([
DefaultConv2D(filters=64, kernel_size=7, input_shape=[28, 28, 1]),
tf.keras.layers.MaxPool2D(),
DefaultConv2D(filters=128),
DefaultConv2D(filters=128),
tf.keras.layers.MaxPool2D(),
DefaultConv2D(filters=256),
DefaultConv2D(filters=256),
tf.keras.layers.MaxPool2D(),
tf.keras.layers.Flatten(),
tf.keras.layers.Dense(units=128, activation="relu",
kernel_initializer="he_normal"),
tf.keras.layers.Dropout(0.5),
tf.keras.layers.Dense(units=64, activation="relu",
kernel_initializer="he_normal"),
tf.keras.layers.Dropout(0.5),
tf.keras.layers.Dense(units=10, activation="softmax")
])
让我们来看一下这段代码:
-
我们使用functools.partial()函数(在第十一章介绍)来定义DefaultConv2D,它的作用就像Conv2D,但具有不同的默认参数:一个小的 3 的内核大小,"same"填充,ReLU 激活函数,以及相应的 He 初始化器。
-
接下来,我们创建Sequential模型。它的第一层是一个具有 64 个相当大的滤波器(7×7)的DefaultConv2D。它使用默认的步幅 1,因为输入图像不是很大。它还设置input_shape=[28, 28, 1],因为图像是 28×28 像素,具有单个颜色通道(即灰度)。当您加载时尚 MNIST 数据集时,请确保每个图像具有这种形状:您可能需要使用np.reshape()或np.expanddims()来添加通道维度。或者,您可以在模型中使用Reshape层作为第一层。
-
然后我们添加一个使用默认池大小为 2 的最大池化层,因此它将每个空间维度除以 2。
-
然后我们重复相同的结构两次:两个卷积层后面跟着一个最大池化层。对于更大的图像,我们可以多次重复这个结构。重复次数是一个可以调整的超参数。
-
请注意,随着我们向 CNN 向输出层上升,滤波器的数量会翻倍(最初为 64,然后为 128,然后为 256):这是有道理的,因为低级特征的数量通常相当低(例如,小圆圈,水平线),但有许多不同的方法可以将它们组合成更高级别的特征。在每个池化层后将滤波器数量翻倍是一种常见做法:由于池化层将每个空间维度除以 2,我们可以在下一层中加倍特征图的数量,而不用担心参数数量、内存使用或计算负载的激增。
-
接下来是全连接网络,由两个隐藏的密集层和一个密集输出层组成。由于这是一个有 10 个类别的分类任务,输出层有 10 个单元,并且使用 softmax 激活函数。请注意,我们必须在第一个密集层之前扁平化输入,因为它期望每个实例的特征是一个 1D 数组。我们还添加了两个 dropout 层,每个的 dropout 率为 50%,以减少过拟合。
如果您使用"sparse_categorical_crossentropy"损失编译此模型,并将模型拟合到 Fashion MNIST 训练集,它应该在测试集上达到超过 92%的准确率。这并不是最先进的,但是相当不错,显然比我们在第十章中使用密集网络取得的成绩要好得多。
多年来,这种基本架构的变体已经被开发出来,导致了该领域的惊人进步。这种进步的一个很好的衡量标准是在 ILSVRC(ImageNet 挑战)等比赛中的错误率。在这个比赛中,图像分类的前五错误率,即系统的前五个预测中没有包括正确答案的测试图像数量,从超过 26%下降到不到 2.3%仅仅用了六年。这些图像相当大(例如,高度为 256 像素),有 1000 个类别,其中一些非常微妙(尝试区分 120 种狗品种)。查看获胜作品的演变是了解 CNN 如何工作以及深度学习研究如何进展的好方法。
我们将首先看一下经典的 LeNet-5 架构(1998 年),然后看一下几位 ILSVRC 挑战的获胜者:AlexNet(2012),GoogLeNet(2014),ResNet(2015)和 SENet(2017)。在此过程中,我们还将看一些其他架构,包括 Xception,ResNeXt,DenseNet,MobileNet,CSPNet 和 EfficientNet。
LeNet-5
LeNet-5 架构可能是最广为人知的 CNN 架构。正如前面提到的,它是由 Yann LeCun 在 1998 年创建的,并且被广泛用于手写数字识别(MNIST)。它由表 14-1 中显示的层组成。
表 14-1. LeNet-5 架构
| 层 |
类型 |
特征图 |
尺寸 |
核大小 |
步幅 |
激活函数 |
| Out |
全连接 |
– |
10 |
– |
– |
RBF |
| F6 |
全连接 |
– |
84 |
– |
– |
tanh |
| C5 |
卷积 |
120 |
1 × 1 |
5 × 5 |
1 |
tanh |
| S4 |
平均池化 |
16 |
5 × 5 |
2 × 2 |
2 |
tanh |
| C3 |
卷积 |
16 |
10 × 10 |
5 × 5 |
1 |
tanh |
| S2 |
平均池化 |
6 |
14 × 14 |
2 × 2 |
2 |
tanh |
| C1 |
卷积 |
6 |
28 × 28 |
5 × 5 |
1 |
tanh |
| In |
输入 |
1 |
32 × 32 |
– |
– |
– |
正如您所看到的,这看起来与我们的时尚 MNIST 模型非常相似:一堆卷积层和池化层,然后是一个密集网络。也许与更现代的分类 CNN 相比,主要的区别在于激活函数:今天,我们会使用 ReLU 而不是 tanh,使用 softmax 而不是 RBF。还有一些其他不太重要的差异,但如果您感兴趣,可以在本章的笔记本中找到https://homl.info/colab3。Yann LeCun 的网站还展示了 LeNet-5 对数字进行分类的精彩演示。
AlexNet
AlexNet CNN 架构¹¹在 2012 年 ILSVRC 挑战赛中大幅领先:它实现了 17%的前五错误率,而第二名竞争对手仅实现了 26%!AlexaNet 由 Alex Krizhevsky(因此得名)、Ilya Sutskever 和 Geoffrey Hinton 开发。它类似于 LeNet-5,只是更大更深,它是第一个直接将卷积层堆叠在一起的模型,而不是将池化层堆叠在每个卷积层之上。表 14-2 展示了这种架构。
表 14-2. AlexNet 架构
| 层 |
类型 |
特征图 |
大小 |
核大小 |
步幅 |
填充 |
激活函数 |
| Out |
全连接 |
– |
1,000 |
– |
– |
– |
Softmax |
| F10 |
全连接 |
– |
4,096 |
– |
– |
– |
ReLU |
| F9 |
全连接 |
– |
4,096 |
– |
– |
– |
ReLU |
| S8 |
最大池化 |
256 |
6 × 6 |
3 × 3 |
2 |
valid |
– |
| C7 |
卷积 |
256 |
13 × 13 |
3 × 3 |
1 |
same |
ReLU |
| C6 |
卷积 |
384 |
13 × 13 |
3 × 3 |
1 |
same |
ReLU |
| C5 |
卷积 |
384 |
13 × 13 |
3 × 3 |
1 |
same |
ReLU |
| S4 |
最大池化 |
256 |
13 × 13 |
3 × 3 |
2 |
valid |
– |
| C3 |
卷积 |
256 |
27 × 27 |
5 × 5 |
1 |
same |
ReLU |
| S2 |
最大池化 |
96 |
27 × 27 |
3 × 3 |
2 |
valid |
– |
| C1 |
卷积 |
96 |
55 × 55 |
11 × 11 |
4 |
valid |
ReLU |
| In |
输入 |
3(RGB) |
227 × 227 |
– |
– |
– |
– |
为了减少过拟合,作者使用了两种正则化技术。首先,他们在训练期间对 F9 和 F10 层的输出应用了 50%的 dropout 率的 dropout(在第十一章中介绍)。其次,他们通过随机移动训练图像的各种偏移量、水平翻转它们和改变光照条件来执行数据增强。
AlexNet 还在 C1 和 C3 层的 ReLU 步骤之后立即使用了一个竞争性归一化步骤,称为局部响应归一化(LRN):最强烈激活的神经元抑制了位于相邻特征图中相同位置的其他神经元。这种竞争性激活已经在生物神经元中观察到。这鼓励不同的特征图专门化,将它们分开并迫使它们探索更广泛的特征,最终提高泛化能力。方程 14-2 展示了如何应用 LRN。
方程 14-2. 局部响应归一化(LRN)
在这个方程中:
-
b[i] 是位于特征图i中的神经元的归一化输出,在某一行u和列v(请注意,在这个方程中,我们只考虑位于这一行和列的神经元,因此u和v没有显示)。
-
a[i] 是 ReLU 步骤后,但规范化之前的神经元的激活。
-
k、α、β和r是超参数。k称为偏置,r称为深度半径。
-
f[n] 是特征图的数量。
例如,如果r = 2,并且一个神经元具有强烈的激活,则它将抑制位于其上下特征图中的神经元的激活。
在 AlexNet 中,超参数设置为:r = 5,α = 0.0001,β = 0.75,k = 2。您可以使用tf.nn.local_response_normalization()函数来实现这一步骤(如果要在 Keras 模型中使用它,可以将其包装在Lambda层中)。
由 Matthew Zeiler 和 Rob Fergus 开发的 AlexNet 的一个变体称为ZF Net¹²,并赢得了 2013 年 ILSVRC 挑战赛。它本质上是 AlexNet,只是调整了一些超参数(特征图数量、卷积核大小、步幅等)。
GoogLeNet
GoogLeNet 架构由 Google Research 的 Christian Szegedy 等人开发,¹³,并通过将前五错误率降低到 7%以下赢得了 ILSVRC 2014 挑战。这一出色的性能在很大程度上来自于该网络比以前的 CNN 更深(如您将在图 14-15 中看到的)。这得益于称为inception 模块的子网络,¹⁴,它允许 GoogLeNet 比以前的架构更有效地使用参数:实际上,GoogLeNet 的参数比 AlexNet 少 10 倍(大约 600 万个而不是 6000 万个)。
图 14-14 显示了 Inception 模块的架构。符号“3×3 + 1(S)”表示该层使用 3×3 内核,步幅 1 和"same"填充。输入信号首先并行输入到四个不同的层中。所有卷积层使用 ReLU 激活函数。请注意,顶部卷积层使用不同的内核大小(1×1、3×3 和 5×5),使它们能够捕获不同尺度的模式。还要注意,每个单独的层都使用步幅 1 和"same"填充(即使是最大池化层),因此它们的输出与它们的输入具有相同的高度和宽度。这使得可以在最终的深度连接层(即将来自所有四个顶部卷积层的特征图堆叠在一起)中沿深度维度连接所有输出。可以使用 Keras 的Concatenate层来实现,使用默认的axis=-1。
![mls3 1414]()
图 14-14。Inception 模块
您可能会想知道为什么 Inception 模块具有具有 1×1 内核的卷积层。毕竟,这些层不能捕获任何特征,因为它们一次只查看一个像素,对吧?实际上,这些层有三个目的:
-
尽管它们不能捕获空间模式,但它们可以捕获沿深度维度(即跨通道)的模式。
-
它们被配置为输出比它们的输入更少的特征图,因此它们充当瓶颈层,意味着它们降低了维度。这降低了计算成本和参数数量,加快了训练速度并提高了泛化能力。
-
每对卷积层([1×1、3×3]和[1×1、5×5])就像一个强大的卷积层,能够捕获更复杂的模式。卷积层等效于在图像上扫过一个密集层(在每个位置,它只查看一个小的感受野),而这些卷积层对等于在图像上扫过两层神经网络。
简而言之,您可以将整个 Inception 模块视为一个超级卷积层,能够输出捕获各种尺度复杂模式的特征图。
现在让我们来看看 GoogLeNet CNN 的架构(参见图 14-15)。每个卷积层和每个池化层输出的特征图数量在内核大小之前显示。该架构非常深,以至于必须用三列来表示,但实际上 GoogLeNet 是一个高高的堆叠,包括九个 Inception 模块(带有旋转顶部的方框)。Inception 模块中的六个数字表示模块中每个卷积层输出的特征图数量(与图 14-14 中的顺序相同)。请注意,所有卷积层都使用 ReLU 激活函数。
让我们来看看这个网络:
-
前两层将图像的高度和宽度分别除以 4(因此其面积除以 16),以减少计算负载。第一层使用大的内核大小,7×7,以便保留大部分信息。
-
然后,本地响应归一化层确保前面的层学习到各种各样的特征(如前面讨论的)。
-
接下来是两个卷积层,其中第一个充当瓶颈层。正如前面提到的,您可以将这一对看作一个更聪明的单个卷积层。
-
再次,本地响应归一化层确保前面的层捕获各种各样的模式。
-
接下来,一个最大池化层将图像的高度和宽度减少了一半,以加快计算速度。
-
然后是 CNN 的骨干:一个高高的堆叠,包括九个 Inception 模块,交替使用一对最大池化层来降低维度并加快网络速度。
-
接下来,全局平均池化层输出每个特征图的平均值:这会丢弃任何剩余的空间信息,这没关系,因为在那一点上剩下的空间信息并不多。事实上,GoogLeNet 的输入图像通常期望为 224×224 像素,因此经过 5 个最大池化层后,每个将高度和宽度除以 2,特征图缩小到 7×7。此外,这是一个分类任务,而不是定位任务,因此物体在哪里并不重要。由于这一层带来的降维,不需要在 CNN 的顶部有几个全连接层(就像在 AlexNet 中那样),这大大减少了网络中的参数数量,并限制了过拟合的风险。
-
最后几层很容易理解:用于正则化的 dropout,然后是一个具有 1,000 个单元的全连接层(因为有 1,000 个类别),以及一个 softmax 激活函数来输出估计的类别概率。
![mls3 1415]()
图 14-15。GoogLeNet 架构
原始的 GoogLeNet 架构包括两个辅助分类器,插在第三和第六个 inception 模块的顶部。它们都由一个平均池化层、一个卷积层、两个全连接层和一个 softmax 激活层组成。在训练过程中,它们的损失(缩小了 70%)被添加到整体损失中。目标是解决梯度消失问题并对网络进行正则化,但后来证明它们的效果相对较小。
后来,Google 的研究人员提出了 GoogLeNet 架构的几个变体,包括 Inception-v3 和 Inception-v4,使用略有不同的 inception 模块以实现更好的性能。
VGGNet
在 ILSVRC 2014 挑战赛中的亚军是VGGNet,Karen Simonyan 和 Andrew Zisserman,来自牛津大学视觉几何组(VGG)研究实验室,开发了一个非常简单和经典的架构;它有 2 或 3 个卷积层和一个池化层,然后再有 2 或 3 个卷积层和一个池化层,依此类推(达到 16 或 19 个卷积层,取决于 VGG 的变体),再加上一个最终的具有 2 个隐藏层和输出层的密集网络。它使用小的 3×3 滤波器,但数量很多。
ResNet
Kaiming He 等人在 ILSVRC 2015 挑战赛中使用Residual Network (ResNet)赢得了冠军,其前五错误率令人惊叹地低于 3.6%。获胜的变体使用了一个由 152 层组成的极深 CNN(其他变体有 34、50 和 101 层)。它证实了一个普遍趋势:计算机视觉模型变得越来越深,参数越来越少。能够训练如此深的网络的关键是使用跳跃连接(也称为快捷连接):输入到一个层的信号也被添加到堆栈中更高的层的输出中。让我们看看为什么这很有用。
在训练神经网络时,目标是使其模拟目标函数h(x)。如果将输入x添加到网络的输出中(即添加一个跳跃连接),那么网络将被迫模拟f(x) = h(x) - x而不是h(x)。这被称为残差学习。
![mls3 1416]()
图 14-16。残差学习
当初始化一个常规的神经网络时,它的权重接近于零,因此网络只会输出接近于零的值。如果添加一个跳跃连接,结果网络将只输出其输入的副本;换句话说,它最初模拟的是恒等函数。如果目标函数与恒等函数相当接近(这通常是情况),这将大大加快训练速度。
此外,如果添加许多跳跃连接,即使有几个层尚未开始学习,网络也可以开始取得进展(参见图 14-17)。由于跳跃连接,信号可以轻松地在整个网络中传播。深度残差网络可以看作是一堆残差单元(RUs),其中每个残差单元是一个带有跳跃连接的小型神经网络。
现在让我们看一下 ResNet 的架构(参见图 14-18)。它非常简单。它的开头和结尾与 GoogLeNet 完全相同(除了没有丢弃层),中间只是一个非常深的残差单元堆栈。每个残差单元由两个卷积层组成(没有池化层!),使用 3×3 的卷积核和保持空间维度(步幅 1,"same"填充)的批量归一化(BN)和 ReLU 激活。
![mls3 1417]()
图 14-17。常规深度神经网络(左)和深度残差网络(右)
![mls3 1418]()
图 14-18。ResNet 架构
请注意,每隔几个残差单元,特征图的数量会加倍,同时它们的高度和宽度会减半(使用步幅为 2 的卷积层)。当这种情况发生时,输入不能直接添加到残差单元的输出中,因为它们的形状不同(例如,这个问题影响了由虚线箭头表示的跳跃连接在图 14-18 中的情况)。为了解决这个问题,输入通过一个步幅为 2 的 1×1 卷积层,并具有正确数量的输出特征图(参见图 14-19)。
![mls3 1419]()
图 14-19。更改特征图大小和深度时的跳跃连接
存在不同变体的架构,具有不同数量的层。ResNet-34 是一个具有 34 层的 ResNet(仅计算卷积层和全连接层),包含 3 个输出 64 个特征图的 RU,4 个输出 128 个特征图的 RU,6 个输出 256 个特征图的 RU,以及 3 个输出 512 个特征图的 RU。我们将在本章后面实现这个架构。
注意
Google 的Inception-v4¹⁸架构融合了 GoogLeNet 和 ResNet 的思想,并在 ImageNet 分类中实现了接近 3%的前五错误率。
比 ResNet-152 更深的 ResNet,例如 ResNet-152,使用略有不同的残差单元。它们不是使用两个具有 256 个特征图的 3×3 卷积层,而是使用三个卷积层:首先是一个只有 64 个特征图的 1×1 卷积层(少了 4 倍),它充当瓶颈层(如前所述),然后是一个具有 64 个特征图的 3×3 层,最后是另一个具有 256 个特征图的 1×1 卷积层(4 倍 64),恢复原始深度。ResNet-152 包含 3 个输出 256 个映射的这样的 RU,然后是 8 个输出 512 个映射的 RU,一个令人惊叹的 36 个输出 1024 个映射的 RU,最后是 3 个输出 2048 个映射的 RU。
Xception
值得注意的是 GoogLeNet 架构的另一个变种:Xception(代表Extreme Inception)由 Keras 的作者 François Chollet 于 2016 年提出,并在一个庞大的视觉任务(3.5 亿张图片和 1.7 万个类别)上明显优于 Inception-v3。就像 Inception-v4 一样,它融合了 GoogLeNet 和 ResNet 的思想,但是用一个特殊类型的层称为深度可分离卷积层(或简称可分离卷积层)替换了 inception 模块。这些层在一些 CNN 架构中之前已经被使用过,但在 Xception 架构中并不像现在这样核心。常规卷积层使用滤波器,试图同时捕捉空间模式(例如,椭圆)和跨通道模式(例如,嘴+鼻子+眼睛=脸),而可分离卷积层则做出了空间模式和跨通道模式可以分别建模的强烈假设(见图 14-20)。因此,它由两部分组成:第一部分对每个输入特征图应用一个单一的空间滤波器,然后第二部分专门寻找跨通道模式——这只是一个具有 1×1 滤波器的常规卷积层。
由于可分离卷积层每个输入通道只有一个空间滤波器,所以应避免在通道较少的层之后使用它们,比如输入层(尽管图 14-20 中是这样的,但那只是为了说明目的)。因此,Xception 架构以 2 个常规卷积层开始,然后剩下的架构只使用可分离卷积(总共 34 个),再加上一些最大池化层和通常的最终层(一个全局平均池化层和一个密集输出层)。
你可能会想为什么 Xception 被认为是 GoogLeNet 的一个变种,因为它根本不包含任何 inception 模块。嗯,正如之前讨论的那样,一个 inception 模块包含有 1×1 滤波器的卷积层:这些滤波器专门寻找跨通道模式。然而,位于它们之上的卷积层是常规卷积层,既寻找空间模式又寻找跨通道模式。因此,你可以将一个 inception 模块看作是一个常规卷积层(同时考虑空间模式和跨通道模式)和一个可分离卷积层(分别考虑它们)之间的中间层。实际上,可分离卷积层通常表现更好。
![mls3 1420]()
图 14-20。深度可分离卷积层
提示
可分离卷积层使用更少的参数、更少的内存和更少的计算量比常规卷积层,通常表现更好。考虑默认使用它们,除了在通道较少的层之后(比如输入通道)。在 Keras 中,只需使用SeparableConv2D代替Conv2D:这是一个即插即用的替代。Keras 还提供了一个DepthwiseConv2D层,实现深度可分离卷积层的第一部分(即,对每个输入特征图应用一个空间滤波器)。
SENet
在 ILSVRC 2017 挑战中获胜的架构是Squeeze-and-Excitation Network (SENet)。这个架构扩展了现有的架构,如 inception 网络和 ResNets,并提升了它们的性能。这使得 SENet 以惊人的 2.25%的前五错误率赢得了比赛!扩展版本的 inception 网络和 ResNets 分别称为SE-Inception和SE-ResNet。提升来自于 SENet 在原始架构的每个 inception 模块或残差单元中添加了一个小型神经网络,称为SE 块,如图 14-21 所示。
![mls3 1421]()
图 14-21. SE-Inception 模块(左)和 SE-ResNet 单元(右)
一个 SE 块分析其所附加的单元的输出,专注于深度维度(不寻找任何空间模式),并学习哪些特征通常是最活跃的。然后,它使用这些信息来重新校准特征映射,如图 14-22 所示。例如,一个 SE 块可能学习到嘴巴、鼻子和眼睛通常一起出现在图片中:如果你看到嘴巴和鼻子,你应该期望也看到眼睛。因此,如果该块在嘴巴和鼻子特征映射中看到强烈的激活,但在眼睛特征映射中只有轻微的激活,它将增强眼睛特征映射(更准确地说,它将减少不相关的特征映射)。如果眼睛有些混淆,这种特征映射的重新校准将有助于解决模糊性。
![mls3 1422]()
图 14-22. 一个 SE 块执行特征映射重新校准
一个 SE 块由三层组成:一个全局平均池化层,一个使用 ReLU 激活函数的隐藏密集层,以及一个使用 sigmoid 激活函数的密集输出层(见图 14-23)。
![mls3 1423]()
图 14-23. SE 块架构
与之前一样,全局平均池化层计算每个特征映射的平均激活:例如,如果其输入包含 256 个特征映射,它将输出 256 个数字,表示每个滤波器的整体响应水平。接下来的层是“挤压”发生的地方:这一层的神经元数量明显少于 256 个——通常比特征映射的数量少 16 倍(例如,16 个神经元)——因此 256 个数字被压缩成一个小向量(例如,16 维)。这是特征响应分布的低维向量表示(即嵌入)。这个瓶颈步骤迫使 SE 块学习特征组合的一般表示(当我们讨论自编码器时,我们将再次看到这个原则在第十七章中)。最后,输出层接受嵌入并输出一个包含每个特征映射的重新校准向量(例如,256 个),每个数字在 0 到 1 之间。然后特征映射乘以这个重新校准向量,因此不相关的特征(具有低重新校准分数)被缩小,而相关的特征(具有接近 1 的重新校准分数)被保留。
其他值得注意的架构
还有许多其他 CNN 架构可以探索。以下是一些最值得注意的简要概述:
ResNeXt²²
ResNeXt 改进了 ResNet 中的残差单元。而最佳 ResNet 模型中的残差单元只包含 3 个卷积层,ResNeXt 的残差单元由许多并行堆栈组成(例如,32 个堆栈),每个堆栈有 3 个卷积层。然而,每个堆栈中的前两层只使用少量滤波器(例如,只有四个),因此总参数数量与 ResNet 中的相同。然后,所有堆栈的输出相加,并将结果传递给下一个残差单元(以及跳跃连接)。
DenseNet²³
DenseNet 由几个密集块组成,每个块由几个密集连接的卷积层组成。这种架构在使用相对较少的参数的同时实现了出色的准确性。什么是“密集连接”?每一层的输出被馈送为同一块内每一层之后的每一层的输入。例如,块中的第 4 层以该块中第 1、2 和 3 层的输出的深度级联作为输入。密集块之间由几个过渡层分隔。
MobileNet²⁴
MobileNets 是精简的模型,旨在轻量且快速,因此在移动和 Web 应用程序中很受欢迎。它们基于深度可分离卷积层,类似于 Xception。作者提出了几个变体,以牺牲一点准确性换取更快速和更小的模型。
CSPNet²⁵
交叉阶段部分网络(CSPNet)类似于 DenseNet,但是每个密集块的部分输入直接连接到该块的输出,而不经过该块。
EfficientNet²⁶
EfficientNet 可以说是这个列表中最重要的模型。作者提出了一种有效地扩展任何 CNN 的方法,通过以原则性的方式同时增加深度(层数)、宽度(每层的滤波器数量)和分辨率(输入图像的大小)。这被称为复合缩放。他们使用神经架构搜索来找到一个适合 ImageNet 的缩小版本(具有更小和更少的图像)的良好架构,然后使用复合缩放来创建这种架构的越来越大的版本。当 EfficientNet 模型推出时,它们在所有计算预算中都远远超过了所有现有的模型,并且它们仍然是当今最好的模型之一。
理解 EfficientNet 的复合缩放方法有助于更深入地理解 CNN,特别是如果您需要扩展 CNN 架构。它基于计算预算的对数度量,标记为ϕ:如果您的计算预算翻倍,则ϕ增加 1。换句话说,用于训练的浮点运算数量与 2^(ϕ)成比例。您的 CNN 架构的深度、宽度和分辨率应分别按α(*ϕ*)、*β*(ϕ)和γ^(ϕ)缩放。因子α、β和γ必须大于 1,且α + β² + γ²应接近 2。这些因子的最佳值取决于 CNN 的架构。为了找到 EfficientNet 架构的最佳值,作者从一个小的基线模型(EfficientNetB0)开始,固定ϕ = 1,然后简单地运行了一个网格搜索:他们发现α = 1.2,β = 1.1,γ = 1.1。然后,他们使用这些因子创建了几个更大的架构,命名为 EfficientNetB1 到 EfficientNetB7,对应不断增加的ϕ值。
选择正确的 CNN 架构
有这么多 CNN 架构,您如何选择最适合您项目的架构?这取决于您最关心的是什么:准确性?模型大小(例如,用于部署到移动设备)?在 CPU 上的推理速度?在 GPU 上的推理速度?表 14-3 列出了目前在 Keras 中可用的最佳预训练模型(您将在本章后面看到如何使用它们),按模型大小排序。您可以在https://keras.io/api/applications找到完整列表。对于每个模型,表格显示要使用的 Keras 类名(在tf.keras.applications包中)、模型的大小(MB)、在 ImageNet 数据集上的 Top-1 和 Top-5 验证准确率、参数数量(百万)以及在 CPU 和 GPU 上使用 32 张图像的推理时间(毫秒),使用性能较强的硬件。²⁷ 对于每列,最佳值已突出显示。正如您所看到的,通常较大的模型更准确,但并非总是如此;例如,EfficientNetB2 在大小和准确性上均优于 InceptionV3。我之所以将 InceptionV3 保留在列表中,是因为在 CPU 上它几乎比 EfficientNetB2 快一倍。同样,InceptionResNetV2 在 CPU 上速度很快,而 ResNet50V2 和 ResNet101V2 在 GPU 上速度极快。
表 14-3。Keras 中可用的预训练模型
| 类名 |
大小(MB) |
Top-1 准确率 |
Top-5 准确率 |
参数 |
CPU(ms) |
GPU(ms) |
| MobileNetV2 |
14 |
71.3% |
90.1% |
3.5M |
25.9 |
3.8 |
| MobileNet |
16 |
70.4% |
89.5% |
4.3M |
22.6 |
3.4 |
| NASNetMobile |
23 |
74.4% |
91.9% |
5.3M |
27.0 |
6.7 |
| EfficientNetB0 |
29 |
77.1% |
93.3% |
5.3M |
46.0 |
4.9 |
| EfficientNetB1 |
31 |
79.1% |
94.4% |
7.9M |
60.2 |
5.6 |
| EfficientNetB2 |
36 |
80.1% |
94.9% |
9.2M |
80.8 |
6.5 |
| EfficientNetB3 |
48 |
81.6% |
95.7% |
12.3M |
140.0 |
8.8 |
| EfficientNetB4 |
75 |
82.9% |
96.4% |
19.5M |
308.3 |
15.1 |
| InceptionV3 |
92 |
77.9% |
93.7% |
23.9M |
42.2 |
6.9 |
| ResNet50V2 |
98 |
76.0% |
93.0% |
25.6M |
45.6 |
4.4 |
| EfficientNetB5 |
118 |
83.6% |
96.7% |
30.6M |
579.2 |
25.3 |
| EfficientNetB6 |
166 |
84.0% |
96.8% |
43.3M |
958.1 |
40.4 |
| ResNet101V2 |
171 |
77.2% |
93.8% |
44.7M |
72.7 |
5.4 |
| InceptionResNetV2 |
215 |
80.3% |
95.3% |
55.9M |
130.2 |
10.0 |
| EfficientNetB7 |
256 |
84.3% |
97.0% |
66.7M |
1578.9 |
61.6 |
希望您喜欢这次对主要 CNN 架构的深入探讨!现在让我们看看如何使用 Keras 实现其中一个。
使用 Keras 实现 ResNet-34 CNN
到目前为止,大多数描述的 CNN 架构可以很自然地使用 Keras 实现(尽管通常您会加载一个预训练网络,正如您将看到的)。为了说明这个过程,让我们使用 Keras 从头开始实现一个 ResNet-34。首先,我们将创建一个ResidualUnit层:
DefaultConv2D = partial(tf.keras.layers.Conv2D, kernel_size=3, strides=1,
padding="same", kernel_initializer="he_normal",
use_bias=False)
class ResidualUnit(tf.keras.layers.Layer):
def __init__(self, filters, strides=1, activation="relu", **kwargs):
super().__init__(**kwargs)
self.activation = tf.keras.activations.get(activation)
self.main_layers = [
DefaultConv2D(filters, strides=strides),
tf.keras.layers.BatchNormalization(),
self.activation,
DefaultConv2D(filters),
tf.keras.layers.BatchNormalization()
]
self.skip_layers = []
if strides > 1:
self.skip_layers = [
DefaultConv2D(filters, kernel_size=1, strides=strides),
tf.keras.layers.BatchNormalization()
]
def call(self, inputs):
Z = inputs
for layer in self.main_layers:
Z = layer(Z)
skip_Z = inputs
for layer in self.skip_layers:
skip_Z = layer(skip_Z)
return self.activation(Z + skip_Z)
正如您所看到的,这段代码与图 14-19 非常接近。在构造函数中,我们创建所有需要的层:图中右侧的主要层和左侧的跳过层(仅在步幅大于 1 时需要)。然后在call()方法中,我们让输入经过主要层和跳过层(如果有的话),然后我们添加两个输出并应用激活函数。
现在我们可以使用Sequential模型构建一个 ResNet-34,因为它实际上只是一长串的层——现在我们有了ResidualUnit类,可以将每个残差单元视为一个单独的层。代码与图 14-18 非常相似:
model = tf.keras.Sequential([
DefaultConv2D(64, kernel_size=7, strides=2, input_shape=[224, 224, 3]),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Activation("relu"),
tf.keras.layers.MaxPool2D(pool_size=3, strides=2, padding="same"),
])
prev_filters = 64
for filters in [64] * 3 + [128] * 4 + [256] * 6 + [512] * 3:
strides = 1 if filters == prev_filters else 2
model.add(ResidualUnit(filters, strides=strides))
prev_filters = filters
model.add(tf.keras.layers.GlobalAvgPool2D())
model.add(tf.keras.layers.Flatten())
model.add(tf.keras.layers.Dense(10, activation="softmax"))
这段代码中唯一棘手的部分是将ResidualUnit层添加到模型的循环:正如前面解释的,前 3 个 RU 有 64 个滤波器,然后接下来的 4 个 RU 有 128 个滤波器,依此类推。在每次迭代中,当滤波器的数量与前一个 RU 中的数量相同时,我们必须将步幅设置为 1;否则,我们将其设置为 2;然后我们添加ResidualUnit,最后我们更新prev_filters。
令人惊讶的是,我们只需大约 40 行代码,就可以构建赢得 ILSVRC 2015 挑战的模型!这既展示了 ResNet 模型的优雅之处,也展示了 Keras API 的表现力。实现其他 CNN 架构会有点长,但并不难。不过,Keras 内置了几种这些架构,为什么不直接使用呢?
使用 Keras 中的预训练模型
通常,您不必手动实现标准模型,如 GoogLeNet 或 ResNet,因为在tf.keras.applications包中只需一行代码即可获得预训练网络。
例如,您可以使用以下代码加载在 ImageNet 上预训练的 ResNet-50 模型:
model = tf.keras.applications.ResNet50(weights="imagenet")
就这些!这将创建一个 ResNet-50 模型,并下载在 ImageNet 数据集上预训练的权重。要使用它,您首先需要确保图像的尺寸正确。ResNet-50 模型期望 224×224 像素的图像(其他模型可能期望其他尺寸,如 299×299),因此让我们使用 Keras 的Resizing层(在第十三章中介绍)来调整两个示例图像的大小(在将它们裁剪到目标纵横比之后):
images = load_sample_images()["images"]
images_resized = tf.keras.layers.Resizing(height=224, width=224,
crop_to_aspect_ratio=True)(images)
预训练模型假定图像以特定方式预处理。在某些情况下,它们可能期望输入被缩放为 0 到 1,或者从-1 到 1 等等。每个模型都提供了一个preprocess_input()函数,您可以用它来预处理您的图像。这些函数假设原始像素值的范围是 0 到 255,这在这里是正确的:
inputs = tf.keras.applications.resnet50.preprocess_input(images_resized)
现在我们可以使用预训练模型进行预测:
>>> Y_proba = model.predict(inputs)
>>> Y_proba.shape
(2, 1000)
像往常一样,输出Y_proba是一个矩阵,每行代表一个图像,每列代表一个类别(在本例中有 1,000 个类别)。如果您想显示前K个预测结果,包括类别名称和每个预测类别的估计概率,请使用decode_predictions()函数。对于每个图像,它返回一个包含前K个预测结果的数组,其中每个预测结果表示为一个包含类别标识符、其名称和相应置信度分数的数组:
top_K = tf.keras.applications.resnet50.decode_predictions(Y_proba, top=3)
for image_index in range(len(images)):
print(f"Image #{image_index}")
for class_id, name, y_proba in top_K[image_index]:
print(f" {class_id} - {name:12s}{y_proba:.2%}")
输出如下所示:
Image #0
n03877845 - palace 54.69%
n03781244 - monastery 24.72%
n02825657 - bell_cote 18.55%
Image #1
n04522168 - vase 32.66%
n11939491 - daisy 17.81%
n03530642 - honeycomb 12.06%
正确的类别是 palace 和 dahlia,因此模型对第一张图像是正确的,但对第二张图像是错误的。然而,这是因为 dahlia 不是 1,000 个 ImageNet 类之一。考虑到这一点,vase 是一个合理的猜测(也许这朵花在花瓶里?),daisy 也不是一个坏选择,因为 dahlias 和 daisies 都属于同一菊科家族。
正如您所看到的,使用预训练模型创建一个相当不错的图像分类器非常容易。正如您在表 14-3 中看到的,tf.keras.applications中提供了许多其他视觉模型,从轻量级快速模型到大型准确模型。
但是,如果您想要为不属于 ImageNet 的图像类别使用图像分类器,那么您仍然可以通过使用预训练模型来进行迁移学习获益。
用于迁移学习的预训练模型
如果您想构建一个图像分类器,但没有足够的数据来从头开始训练它,那么通常可以重用预训练模型的较低层,正如我们在第十一章中讨论的那样。例如,让我们训练一个模型来对花的图片进行分类,重用一个预训练的 Xception 模型。首先,我们将使用 TensorFlow Datasets(在第十三章中介绍)加载花卉数据集:
import tensorflow_datasets as tfds
dataset, info = tfds.load("tf_flowers", as_supervised=True, with_info=True)
dataset_size = info.splits["train"].num_examples # 3670
class_names = info.features["label"].names # ["dandelion", "daisy", ...]
n_classes = info.features["label"].num_classes # 5
请注意,您可以通过设置with_info=True来获取有关数据集的信息。在这里,我们获取数据集的大小和类的名称。不幸的是,只有一个"train"数据集,没有测试集或验证集,所以我们需要拆分训练集。让我们再次调用tfds.load(),但这次将前 10%的数据集用于测试,接下来的 15%用于验证,剩下的 75%用于训练:
test_set_raw, valid_set_raw, train_set_raw = tfds.load(
"tf_flowers",
split=["train[:10%]", "train[10%:25%]", "train[25%:]"],
as_supervised=True)
所有三个数据集都包含单独的图像。我们需要对它们进行批处理,但首先我们需要确保它们都具有相同的大小,否则批处理将失败。我们可以使用Resizing层来实现这一点。我们还必须调用tf.keras.applications.xception.preprocess_input()函数,以适当地预处理图像以供 Xception 模型使用。最后,我们还将对训练集进行洗牌并使用预取:
batch_size = 32
preprocess = tf.keras.Sequential([
tf.keras.layers.Resizing(height=224, width=224, crop_to_aspect_ratio=True),
tf.keras.layers.Lambda(tf.keras.applications.xception.preprocess_input)
])
train_set = train_set_raw.map(lambda X, y: (preprocess(X), y))
train_set = train_set.shuffle(1000, seed=42).batch(batch_size).prefetch(1)
valid_set = valid_set_raw.map(lambda X, y: (preprocess(X), y)).batch(batch_size)
test_set = test_set_raw.map(lambda X, y: (preprocess(X), y)).batch(batch_size)
现在每个批次包含 32 个图像,所有图像都是 224×224 像素,像素值范围从-1 到 1。完美!
由于数据集不是很大,一点数据增强肯定会有所帮助。让我们创建一个数据增强模型,将其嵌入到我们的最终模型中。在训练期间,它将随机水平翻转图像,稍微旋转它们,并调整对比度:
data_augmentation = tf.keras.Sequential([
tf.keras.layers.RandomFlip(mode="horizontal", seed=42),
tf.keras.layers.RandomRotation(factor=0.05, seed=42),
tf.keras.layers.RandomContrast(factor=0.2, seed=42)
])
提示
tf.keras.preprocessing.image.ImageDataGenerator类使从磁盘加载图像并以各种方式增强它们变得容易:您可以移动每个图像,旋转它,重新缩放它,水平或垂直翻转它,剪切它,或者应用任何您想要的转换函数。这对于简单的项目非常方便。然而,tf.data 管道并不复杂,通常更快。此外,如果您有 GPU 并且将预处理或数据增强层包含在模型内部,它们将在训练过程中受益于 GPU 加速。
接下来让我们加载一个在 ImageNet 上预训练的 Xception 模型。通过设置include_top=False来排除网络的顶部。这将排除全局平均池化层和密集输出层。然后我们添加自己的全局平均池化层(将其输入设置为基础模型的输出),然后是一个具有每个类别一个单元的密集输出层,使用 softmax 激活函数。最后,我们将所有这些包装在一个 Keras Model中:
base_model = tf.keras.applications.xception.Xception(weights="imagenet",
include_top=False)
avg = tf.keras.layers.GlobalAveragePooling2D()(base_model.output)
output = tf.keras.layers.Dense(n_classes, activation="softmax")(avg)
model = tf.keras.Model(inputs=base_model.input, outputs=output)
如第十一章中解释的,通常冻结预训练层的权重是一个好主意,至少在训练开始时是这样的:
for layer in base_model.layers:
layer.trainable = False
警告
由于我们的模型直接使用基础模型的层,而不是base_model对象本身,设置base_model.trainable=False不会产生任何效果。
最后,我们可以编译模型并开始训练:
optimizer = tf.keras.optimizers.SGD(learning_rate=0.1, momentum=0.9)
model.compile(loss="sparse_categorical_crossentropy", optimizer=optimizer,
metrics=["accuracy"])
history = model.fit(train_set, validation_data=valid_set, epochs=3)
警告
如果你在 Colab 上运行,请确保运行时正在使用 GPU:选择运行时→“更改运行时类型”,在“硬件加速器”下拉菜单中选择“GPU”,然后点击保存。可以在没有 GPU 的情况下训练模型,但速度会非常慢(每个时期几分钟,而不是几秒)。
在训练模型几个时期后,其验证准确率应该达到 80%以上,然后停止提高。这意味着顶层现在已经训练得相当好,我们准备解冻一些基础模型的顶层,然后继续训练。例如,让我们解冻第 56 层及以上的层(这是 14 个残差单元中第 7 个的开始,如果列出层名称,你会看到):
for layer in base_model.layers[56:]:
layer.trainable = True
不要忘记在冻结或解冻层时编译模型。还要确保使用更低的学习率以避免破坏预训练权重:
optimizer = tf.keras.optimizers.SGD(learning_rate=0.01, momentum=0.9)
model.compile(loss="sparse_categorical_crossentropy", optimizer=optimizer,
metrics=["accuracy"])
history = model.fit(train_set, validation_data=valid_set, epochs=10)
这个模型应该在测试集上达到大约 92%的准确率,在几分钟的训练时间内(使用 GPU)。如果调整超参数,降低学习率,并进行更长时间的训练,应该能够达到 95%至 97%的准确率。有了这个,你可以开始在自己的图像和类别上训练出色的图像分类器!但计算机视觉不仅仅是分类。例如,如果你还想知道图片中花朵的位置在哪里?让我们现在来看看。
分类和定位
在图片中定位一个对象可以被表达为一个回归任务,如第十章中讨论的:预测一个对象周围的边界框,一个常见的方法是预测对象中心的水平和垂直坐标,以及它的高度和宽度。这意味着我们有四个数字要预测。对模型不需要太多改变;我们只需要添加一个具有四个单元的第二个密集输出层(通常在全局平均池化层之上),并且可以使用 MSE 损失进行训练:
base_model = tf.keras.applications.xception.Xception(weights="imagenet",
include_top=False)
avg = tf.keras.layers.GlobalAveragePooling2D()(base_model.output)
class_output = tf.keras.layers.Dense(n_classes, activation="softmax")(avg)
loc_output = tf.keras.layers.Dense(4)(avg)
model = tf.keras.Model(inputs=base_model.input,
outputs=[class_output, loc_output])
model.compile(loss=["sparse_categorical_crossentropy", "mse"],
loss_weights=[0.8, 0.2], # depends on what you care most about
optimizer=optimizer, metrics=["accuracy"])
但是现在我们有一个问题:花卉数据集中没有围绕花朵的边界框。因此,我们需要自己添加。这通常是机器学习项目中最困难和最昂贵的部分之一:获取标签。花时间寻找合适的工具是个好主意。要用边界框注释图像,您可能想使用开源图像标注工具,如 VGG Image Annotator、LabelImg、OpenLabeler 或 ImgLab,或者商业工具如 LabelBox 或 Supervisely。您还可以考虑众包平台,如亚马逊机械土耳其,如果您有大量图像需要注释。然而,设置众包平台、准备发送给工人的表格、监督他们并确保他们产生的边界框的质量是好的,这是相当多的工作,所以确保这是值得的。Adriana Kovashka 等人撰写了一篇非常实用的论文关于计算机视觉中的众包。我建议您查看一下,即使您不打算使用众包。如果只有几百张甚至几千张图像需要标记,并且您不打算经常这样做,最好自己做:使用合适的工具,只需要几天时间,您还将更好地了解您的数据集和任务。
现在假设您已经为花卉数据集中的每个图像获得了边界框(暂时假设每个图像只有一个边界框)。然后,您需要创建一个数据集,其项目将是经过预处理的图像的批次以及它们的类标签和边界框。每个项目应该是一个形式为(images, (class_labels, bounding_boxes))的元组。然后您就可以开始训练您的模型!
提示
边界框应该被归一化,使得水平和垂直坐标以及高度和宽度的范围都在 0 到 1 之间。此外,通常预测高度和宽度的平方根,而不是直接预测高度和宽度:这样,对于大边界框的 10 像素误差不会受到与小边界框的 10 像素误差一样多的惩罚。
均方误差通常作为训练模型的成本函数效果相当不错,但不是评估模型如何预测边界框的好指标。这方面最常见的度量是交并比(IoU):预测边界框与目标边界框之间的重叠区域除以它们的并集的面积(参见图 14-24)。在 Keras 中,它由tf.keras.metrics.MeanIoU类实现。
对单个对象进行分类和定位是很好的,但是如果图像中包含多个对象(通常在花卉数据集中是这种情况),怎么办呢?
![mls3 1424]()
图 14-24。边界框的 IoU 度量
目标检测
在图像中对多个对象进行分类和定位的任务称为目标检测。直到几年前,一种常见的方法是采用一个 CNN,该 CNN 经过训练,可以对图像中大致位于中心的单个对象进行分类和定位,然后在图像上滑动这个 CNN,并在每一步进行预测。通常,CNN 被训练来预测不仅类别概率和边界框,还有一个对象性分数:这是估计的概率,即图像确实包含一个位于中心附近的对象。这是一个二元分类输出;它可以通过具有单个单元的密集输出层产生,使用 sigmoid 激活函数并使用二元交叉熵损失进行训练。
注意
有时会添加一个“无对象”类,而不是对象性分数,但总的来说,这并不起作用得很好:最好分开回答“是否存在对象?”和“对象的类型是什么?”这两个问题。
这种滑动 CNN 方法在图 14-25 中有所说明。在这个例子中,图像被切成了一个 5×7 的网格,我们看到一个 CNN——厚厚的黑色矩形——在所有 3×3 区域上滑动,并在每一步进行预测。
![mls3 1425]()
图 14-25。通过在图像上滑动 CNN 来检测多个对象
在这个图中,CNN 已经对这三个 3×3 区域进行了预测:
-
当查看左上角的 3×3 区域(位于第二行第二列的红色阴影网格单元中心)时,它检测到了最左边的玫瑰。请注意,预测的边界框超出了这个 3×3 区域的边界。这完全没问题:即使 CNN 看不到玫瑰的底部部分,它仍能合理猜测它可能在哪里。它还预测了类别概率,给“玫瑰”类别一个很高的概率。最后,它预测了一个相当高的物体得分,因为边界框的中心位于中心网格单元内(在这个图中,物体得分由边界框的厚度表示)。
-
当查看下一个 3×3 区域,向右移动一个网格单元(位于阴影蓝色正方形中心)时,它没有检测到任何位于该区域中心的花朵,因此预测的物体得分非常低;因此,可以安全地忽略预测的边界框和类别概率。您可以看到,预测的边界框也不好。
-
最后,当查看下一个 3×3 区域,再向右移动一个网格单元(位于阴影绿色单元中心)时,它检测到了顶部的玫瑰,尽管不完美:这朵玫瑰没有很好地位于该区域中心,因此预测的物体得分并不是很高。
您可以想象,将 CNN 滑动到整个图像上会给您总共 15 个预测的边界框,以 3×5 的网格组织,每个边界框都伴随着其估计的类别概率和物体得分。由于对象的大小可能不同,您可能希望再次在更大的 4×4 区域上滑动 CNN,以获得更多的边界框。
这种技术相当简单,但正如您所看到的,它经常会在稍微不同的位置多次检测到相同的对象。需要一些后处理来摆脱所有不必要的边界框。一个常见的方法是称为非极大值抑制。下面是它的工作原理:
-
首先,摆脱所有物体得分低于某个阈值的边界框:因为 CNN 认为该位置没有对象,所以边界框是无用的。
-
找到具有最高物体得分的剩余边界框,并摆脱所有与其重叠很多的其他剩余边界框(例如,IoU 大于 60%)。例如,在图 14-25 中,具有最大物体得分的边界框是覆盖最左边的玫瑰的厚边界框。与这朵相同玫瑰接触的另一个边界框与最大边界框重叠很多,因此我们将摆脱它(尽管在这个例子中,它在上一步中已经被移除)。
-
重复步骤 2,直到没有更多需要摆脱的边界框。
这种简单的目标检测方法效果相当不错,但需要多次运行 CNN(在这个例子中为 15 次),因此速度相当慢。幸运的是,有一种更快的方法可以在图像上滑动 CNN:使用全卷积网络(FCN)。
全卷积网络
FCN 的概念最初是由 Jonathan Long 等人在2015 年的一篇论文中提出的,用于语义分割(根据对象所属的类别对图像中的每个像素进行分类的任务)。作者指出,可以用卷积层替换 CNN 顶部的密集层。为了理解这一点,让我们看一个例子:假设一个具有 200 个神经元的密集层位于一个输出 100 个大小为 7×7 的特征图的卷积层的顶部(这是特征图的大小,而不是卷积核的大小)。每个神经元将计算来自卷积层的所有 100×7×7 激活的加权和(加上一个偏置项)。现在让我们看看如果我们用 200 个大小为 7×7 的滤波器和"valid"填充的卷积层来替换密集层会发生什么。这一层将输出 200 个大小为 1×1 的特征图(因为卷积核恰好是输入特征图的大小,而且我们使用"valid"填充)。换句话说,它将输出 200 个数字,就像密集层一样;如果你仔细观察卷积层执行的计算,你会注意到这些数字将与密集层产生的数字完全相同。唯一的区别是密集层的输出是一个形状为[批量大小, 200]的张量,而卷积层将输出一个形状为[批量大小, 1, 1, 200]的张量。
提示
要将密集层转换为卷积层,卷积层中的滤波器数量必须等于密集层中的单元数量,滤波器大小必须等于输入特征图的大小,并且必须使用"valid"填充。步幅可以设置为 1 或更多,稍后您将看到。
为什么这很重要?嗯,密集层期望特定的输入大小(因为它对每个输入特征有一个权重),而卷积层将愉快地处理任何大小的图像(但是,它期望其输入具有特定数量的通道,因为每个卷积核包含每个输入通道的不同权重集)。由于 FCN 只包含卷积层(和具有相同属性的池化层),它可以在任何大小的图像上进行训练和执行!
例如,假设我们已经训练了一个用于花卉分类和定位的 CNN。它是在 224×224 的图像上训练的,并输出 10 个数字:
-
输出 0 到 4 通过 softmax 激活函数发送,这给出了类别概率(每个类别一个)。
-
输出 5 通过 sigmoid 激活函数发送,这给出了物体得分。
-
输出 6 和 7 代表边界框的中心坐标;它们也经过 sigmoid 激活函数,以确保它们的范围在 0 到 1 之间。
-
最后,输出 8 和 9 代表边界框的高度和宽度;它们不经过任何激活函数,以允许边界框延伸到图像的边界之外。
现在我们可以将 CNN 的密集层转换为卷积层。实际上,我们甚至不需要重新训练它;我们可以直接将密集层的权重复制到卷积层!或者,在训练之前,我们可以将 CNN 转换为 FCN。
现在假设在输出层之前的最后一个卷积层(也称为瓶颈层)在网络输入 224×224 图像时输出 7×7 特征图(参见图 14-26 的左侧)。如果我们将 FCN 输入 448×448 图像(参见图 14-26 的右侧),瓶颈层现在将输出 14×14 特征图。³² 由于密集输出层被使用大小为 7×7 的 10 个滤波器的卷积层替换,使用"valid"填充和步幅 1,输出将由 10 个特征图组成,每个大小为 8×8(因为 14-7+1=8)。换句话说,FCN 将仅处理整个图像一次,并输出一个 8×8 的网格,其中每个单元包含 10 个数字(5 个类别概率,1 个物体性分数和 4 个边界框坐标)。这就像拿着原始 CNN 并在图像上每行移动 8 步,每列移动 8 步。为了可视化这一点,想象将原始图像切成一个 14×14 的网格,然后在这个网格上滑动一个 7×7 的窗口;窗口将有 8×8=64 个可能的位置,因此有 8×8 个预测。然而,FCN 方法要更有效,因为网络只看一次图像。事实上,You Only Look Once(YOLO)是一个非常流行的目标检测架构的名称,我们将在接下来看一下。
![mls3 1426]()
图 14-26。相同的全卷积网络处理小图像(左)和大图像(右)
只看一次
YOLO 是由 Joseph Redmon 等人在2015 年的一篇论文中提出的一种快速准确的目标检测架构。³³ 它非常快速,可以在视频上实时运行,就像在 Redmon 的演示中看到的那样。YOLO 的架构与我们刚讨论的架构非常相似,但有一些重要的区别:
-
对于每个网格单元,YOLO 只考虑边界框中心位于该单元内的对象。边界框坐标是相对于该单元的,其中(0, 0)表示单元的左上角,(1, 1)表示右下角。然而,边界框的高度和宽度可能远远超出单元。
-
它为每个网格单元输出两个边界框(而不仅仅是一个),这使得模型能够处理两个对象非常接近,以至于它们的边界框中心位于同一个单元格内的情况。每个边界框还附带自己的物体性分数。
-
YOLO 还为每个网格单元输出一个类别概率分布,每个网格单元预测 20 个类别概率,因为 YOLO 是在包含 20 个类别的 PASCAL VOC 数据集上训练的。这产生了一个粗糙的类别概率图。请注意,模型为每个网格单元预测一个类别概率分布,而不是每个边界框。然而,可以在后处理期间估计每个边界框的类别概率,方法是测量每个边界框与类别概率图中的每个类别匹配的程度。例如,想象一张图片中有一个人站在一辆车前面。将会有两个边界框:一个大的水平边界框用于车,一个较小的垂直边界框用于人。这些边界框的中心可能位于同一个网格单元内。那么我们如何确定应该为每个边界框分配哪个类别呢?嗯,类别概率图将包含一个“车”类占主导地位的大区域,里面将有一个“人”类占主导地位的较小区域。希望车的边界框大致匹配“车”区域,而人的边界框大致匹配“人”区域:这将允许为每个边界框分配正确的类别。
YOLO 最初是使用 Darknet 开发的,Darknet 是由 Joseph Redmon 最初用 C 开发的开源深度学习框架,但很快就被移植到了 TensorFlow、Keras、PyTorch 等。多年来不断改进,包括 YOLOv2、YOLOv3 和 YOLO9000(再次由 Joseph Redmon 等人开发)、YOLOv4(由 Alexey Bochkovskiy 等人开发)、YOLOv5(由 Glenn Jocher 开发)和 PP-YOLO(由 Xiang Long 等人开发)。
每个版本都带来了一些令人印象深刻的速度和准确性改进,使用了各种技术;例如,YOLOv3 在一定程度上提高了准确性,部分原因在于锚先验,利用了某些边界框形状比其他形状更有可能的事实,这取决于类别(例如,人们倾向于具有垂直边界框,而汽车通常不会)。他们还增加了每个网格单元的边界框数量,他们在不同数据集上进行了训练,包含更多类别(YOLO9000 的情况下最多达到 9,000 个类别,按层次结构组织),他们添加了跳跃连接以恢复在 CNN 中丢失的一些空间分辨率(我们将很快讨论这一点,当我们看语义分割时),等等。这些模型也有许多变体,例如 YOLOv4-tiny,它经过优化,可以在性能较弱的机器上进行训练,并且可以运行得非常快(每秒超过 1,000 帧!),但平均精度均值(mAP)略低。
许多目标检测模型都可以在 TensorFlow Hub 上找到,通常具有预训练权重,例如 YOLOv5、SSD、Faster R-CNN和EfficientDet。
SSD 和 EfficientDet 是“一次查看”检测模型,类似于 YOLO。EfficientDet 基于 EfficientNet 卷积架构。Faster R-CNN 更复杂:图像首先经过 CNN,然后输出传递给区域建议网络(RPN),该网络提出最有可能包含对象的边界框;然后为每个边界框运行分类器,基于 CNN 的裁剪输出。使用这些模型的最佳起点是 TensorFlow Hub 的出色目标检测教程。
到目前为止,我们只考虑在单个图像中检测对象。但是视频呢?对象不仅必须在每一帧中被检测到,还必须随着时间进行跟踪。现在让我们快速看一下目标跟踪。
目标跟踪
目标跟踪是一项具有挑战性的任务:对象移动,它们可能随着接近或远离摄像机而变大或变小,它们的外观可能会随着转身或移动到不同的光照条件或背景而改变,它们可能会被其他对象暂时遮挡,等等。
最受欢迎的目标跟踪系统之一是DeepSORT。它基于经典算法和深度学习的组合:
-
它使用Kalman 滤波器来估计给定先前检测的对象最可能的当前位置,并假设对象倾向于以恒定速度移动。
-
它使用深度学习模型来衡量新检测和现有跟踪对象之间的相似度。
-
最后,它使用匈牙利算法将新检测映射到现有跟踪对象(或新跟踪对象):该算法有效地找到最小化检测和跟踪对象预测位置之间距离的映射组合,同时最小化外观差异。
例如,想象一个红色球刚从相反方向移动的蓝色球上弹起。根据球的先前位置,卡尔曼滤波器将预测球会相互穿过:实际上,它假设对象以恒定速度移动,因此不会预期弹跳。如果匈牙利算法只考虑位置,那么它会愉快地将新的检测结果映射到错误的球上,就好像它们刚刚相互穿过并交换了颜色。但由于相似度度量,匈牙利算法会注意到问题。假设球不太相似,算法将新的检测结果映射到正确的球上。
提示
在 GitHub 上有一些 DeepSORT 的实现,包括 YOLOv4 + DeepSORT 的 TensorFlow 实现:https://github.com/theAIGuysCode/yolov4-deepsort。
到目前为止,我们已经使用边界框定位了对象。这通常足够了,但有时您需要更精确地定位对象,例如在视频会议中去除人物背后的背景。让我们看看如何降到像素级别。
语义分割
在语义分割中,每个像素根据其所属对象的类别进行分类(例如,道路、汽车、行人、建筑等),如图 14-27 所示。请注意,同一类别的不同对象不被区分。例如,分割图像右侧的所有自行车最终会成为一个大块像素。这项任务的主要困难在于,当图像经过常规 CNN 时,由于步幅大于 1 的层,它们逐渐失去空间分辨率;因此,常规 CNN 可能只会知道图像左下角某处有一个人,但不会比这更精确。
![mls3 1427]()
图 14-27. 语义分割
与目标检测一样,有许多不同的方法来解决这个问题,有些方法相当复杂。然而,在 Jonathan Long 等人于 2015 年提出的一篇关于完全卷积网络的论文中提出了一个相当简单的解决方案。作者首先采用了一个预训练的 CNN,并将其转换为 FCN。CNN 对输入图像应用了总步幅为 32(即,如果将所有大于 1 的步幅相加),这意味着最后一层输出的特征图比输入图像小 32 倍。这显然太粗糙了,因此他们添加了一个单一的上采样层,将分辨率乘以 32。
有几种可用的上采样解决方案(增加图像的大小),例如双线性插值,但这只能在×4 或×8 的范围内工作得相当好。相反,他们使用转置卷积层:³⁹这相当于首先通过插入空行和列(全是零)来拉伸图像,然后执行常规卷积(参见图 14-28)。或者,有些人更喜欢将其视为使用分数步幅的常规卷积层(例如,图 14-28 中的步幅为 1/2)。转置卷积层可以初始化为执行接近线性插值的操作,但由于它是一个可训练的层,在训练期间会学习做得更好。在 Keras 中,您可以使用Conv2DTranspose层。
注意
在转置卷积层中,步幅定义了输入将被拉伸多少,而不是滤波器步长的大小,因此步幅越大,输出就越大(与卷积层或池化层不同)。
![mls3 1428]()
图 14-28. 使用转置卷积层进行上采样
使用转置卷积层进行上采样是可以的,但仍然太不精确。为了做得更好,Long 等人从较低层添加了跳跃连接:例如,他们将输出图像上采样了 2 倍(而不是 32 倍),并添加了具有这种双倍分辨率的较低层的输出。然后,他们将结果上采样了 16 倍,导致总的上采样因子为 32(参见图 14-29)。这恢复了在较早的池化层中丢失的一些空间分辨率。在他们最好的架构中,他们使用了第二个类似的跳跃连接,以从更低的层中恢复更精细的细节。简而言之,原始 CNN 的输出经过以下额外步骤:上采样×2,添加较低层的输出(适当比例),上采样×2,添加更低层的输出,最后上采样×8。甚至可以将缩放超出原始图像的大小:这可以用于增加图像的分辨率,这是一种称为超分辨率的技术。
![mls3 1429]()
图 14-29。跳跃层从较低层恢复一些空间分辨率
实例分割类似于语义分割,但不是将同一类别的所有对象合并成一个大块,而是将每个对象与其他对象区分开来(例如,它识别每辆自行车)。例如,由 Kaiming He 等人在2017 年的一篇论文中提出的Mask R-CNN架构,通过为每个边界框额外生成一个像素掩码来扩展 Faster R-CNN 模型。因此,您不仅可以获得围绕每个对象的边界框,以及一组估计的类别概率,还可以获得一个像素掩码,该掩码定位属于对象的边界框中的像素。该模型可在 TensorFlow Hub 上获得,预训练于 COCO 2017 数据集。尽管该领域发展迅速,但如果您想尝试最新和最优秀的模型,请查看https://paperswithcode.com的最新技术部分。
正如您所看到的,深度计算机视觉领域广阔且快速发展,每年都会涌现出各种架构。几乎所有这些架构都基于卷积神经网络,但自 2020 年以来,另一种神经网络架构已进入计算机视觉领域:Transformer(我们将在第十六章中讨论)。过去十年取得的进步令人瞩目,研究人员现在正专注于越来越困难的问题,例如对抗学习(试图使网络更具抗干扰性,以防止被设计用来欺骗它的图像)、可解释性(了解网络为何做出特定分类)、现实图像生成(我们将在第十七章中回顾)、单次学习(一个系统只需看到一次对象就能识别该对象)、预测视频中的下一帧、结合文本和图像任务等等。
现在进入下一章,我们将看看如何使用递归神经网络和卷积神经网络处理序列数据,例如时间序列。
练习
-
相比于完全连接的 DNN,CNN 在图像分类方面有哪些优势?
-
考虑一个由三个卷积层组成的 CNN,每个卷积层都有 3×3 的内核,步幅为 2,且具有"same"填充。最底层输出 100 个特征映射,中间层输出 200 个,顶层输出 400 个。输入图像是 200×300 像素的 RGB 图像:
-
CNN 中的参数总数是多少?
-
如果我们使用 32 位浮点数,那么在对单个实例进行预测时,这个网络至少需要多少 RAM?
-
当在一个包含 50 张图像的小批量上进行训练时呢?
-
如果您的 GPU 在训练 CNN 时内存不足,您可以尝试哪五种方法来解决这个问题?
-
为什么要添加最大池化层而不是具有相同步幅的卷积层?
-
何时要添加局部响应归一化层?
-
您能否列出 AlexNet 相对于 LeNet-5 的主要创新?GoogLeNet、ResNet、SENet、Xception 和 EfficientNet 的主要创新又是什么?
-
什么是全卷积网络?如何将密集层转换为卷积层?
-
语义分割的主要技术难点是什么?
-
从头开始构建自己的 CNN,并尝试在 MNIST 上实现最高可能的准确性。
-
使用大型图像分类的迁移学习,经过以下步骤:
-
创建一个包含每类至少 100 张图像的训练集。例如,您可以根据位置(海滩、山脉、城市等)对自己的图片进行分类,或者您可以使用现有数据集(例如来自 TensorFlow 数据集)。
-
将其分为训练集、验证集和测试集。
-
构建输入管道,应用适当的预处理操作,并可选择添加数据增强。
-
在这个数据集上微调一个预训练模型。
-
按照 TensorFlow 的风格转移教程进行操作。这是使用深度学习生成艺术的有趣方式。
这些练习的解决方案可在本章笔记本的末尾找到,网址为https://homl.info/colab3。
(1)David H. Hubel,“不受限制的猫条纹皮层单元活动”,《生理学杂志》147 卷(1959 年):226-238。
(2)David H. Hubel 和 Torsten N. Wiesel,“猫条纹皮层单个神经元的感受野”,《生理学杂志》148 卷(1959 年):574-591。
(3)David H. Hubel 和 Torsten N. Wiesel,“猴子条纹皮层的感受野和功能结构”,《生理学杂志》195 卷(1968 年):215-243。
(4)福岛邦彦,“Neocognitron:一种不受位置偏移影响的模式识别机制的自组织神经网络模型”,《生物控制论》36 卷(1980 年):193-202。
(5)Yann LeCun 等人,“基于梯度的学习应用于文档识别”,《IEEE 会议录》86 卷,第 11 期(1998 年):2278-2324。
(6)卷积是一种数学操作,它将一个函数滑动到另一个函数上,并测量它们逐点乘积的积分。它与傅里叶变换和拉普拉斯变换有深刻的联系,并且在信号处理中被广泛使用。卷积层实际上使用交叉相关,这与卷积非常相似(有关更多详细信息,请参见https://homl.info/76)。
(7)为了产生相同大小的输出,一个全连接层需要 200×150×100 个神经元,每个神经元连接到所有 150×100×3 个输入。它将有 200×150×100×(150×100×3+1)≈1350 亿个参数!
(8)在国际单位制(SI)中,1 MB = 1,000 KB = 1,000×1,000 字节 = 1,000×1,000×8 位。而 1 MiB = 1,024 kiB = 1,024×1,024 字节。所以 12 MB ≈ 11.44 MiB。
(9)我们迄今讨论过的其他内核具有权重,但池化内核没有:它们只是无状态的滑动窗口。
(10)Yann LeCun 等人,“基于梯度的学习应用于文档识别”,《IEEE 会议录》86 卷,第 11 期(1998 年):2278-2324。
(11)Alex Krizhevsky 等人,“使用深度卷积神经网络对 ImageNet 进行分类”,《第 25 届国际神经信息处理系统会议论文集》1 卷(2012 年):1097-1105。
¹² Matthew D. Zeiler 和 Rob Fergus,“可视化和理解卷积网络”,欧洲计算机视觉会议论文集(2014):818-833。
¹³ Christian Szegedy 等人,“使用卷积深入”,IEEE 计算机视觉和模式识别会议论文集(2015):1-9。
¹⁴ 在 2010 年的电影Inception中,角色们不断深入多层梦境;因此这些模块的名称。
¹⁵ Karen Simonyan 和 Andrew Zisserman,“用于大规模图像识别的非常深的卷积网络”,arXiv 预印本 arXiv:1409.1556(2014)。
¹⁶ Kaiming He 等人,“用于图像识别的深度残差学习”,arXiv 预印本 arXiv:1512:03385(2015)。
¹⁷ 描述神经网络时,通常只计算具有参数的层。
¹⁸ Christian Szegedy 等人,“Inception-v4,Inception-ResNet 和残差连接对学习的影响”,arXiv 预印本 arXiv:1602.07261(2016)。
¹⁹ François Chollet,“Xception:深度学习与深度可分离卷积”,arXiv 预印本 arXiv:1610.02357(2016)。
²⁰ 这个名称有时可能会有歧义,因为空间可分离卷积通常也被称为“可分离卷积”。
²¹ Jie Hu 等人,“挤压激励网络”,IEEE 计算机视觉和模式识别会议论文集(2018):7132-7141。
²² Saining Xie 等人,“聚合残差变换用于深度神经网络”,arXiv 预印本 arXiv:1611.05431(2016)。
²³ Gao Huang 等人,“密集连接卷积网络”,arXiv 预印本 arXiv:1608.06993(2016)。
²⁴ Andrew G. Howard 等人,“MobileNets:用于移动视觉应用的高效卷积神经网络”,arXiv 预印本 arxiv:1704.04861(2017)。
²⁵ Chien-Yao Wang 等人,“CSPNet:一种可以增强 CNN 学习能力的新骨干”,arXiv 预印本 arXiv:1911.11929(2019)。
²⁶ Mingxing Tan 和 Quoc V. Le,“EfficientNet:重新思考卷积神经网络的模型缩放”,arXiv 预印本 arXiv:1905.11946(2019)。
²⁷ 一款 92 核心的 AMD EPYC CPU,带有 IBPB,1.7 TB 的 RAM 和一款 Nvidia Tesla A100 GPU。
²⁸ 在 ImageNet 数据集中,每个图像都映射到WordNet 数据集中的一个单词:类别 ID 只是一个 WordNet ID。
²⁹ Adriana Kovashka 等人,“计算机视觉中的众包”,计算机图形学和视觉基础与趋势 10,第 3 期(2014):177-243。
³⁰ Jonathan Long 等人,“用于语义分割的全卷积网络”,IEEE 计算机视觉和模式识别会议论文集(2015):3431-3440。
³¹ 有一个小例外:使用"valid"填充的卷积层会在输入大小小于核大小时报错。
³² 这假设我们在网络中只使用了"same"填充:"valid"填充会减小特征图的大小。此外,448 可以被 2 整除多次,直到达到 7,没有任何舍入误差。如果任何一层使用不同于 1 或 2 的步幅,那么可能会有一些舍入误差,因此特征图最终可能会变小。
³³ Joseph Redmon 等人,“You Only Look Once: Unified, Real-Time Object Detection”,《IEEE 计算机视觉与模式识别会议论文集》(2016):779–788。
³⁴ 您可以在 TensorFlow Models 项目中找到 YOLOv3、YOLOv4 及其微小变体,网址为https://homl.info/yolotf。
³⁵ Wei Liu 等人,“SSD: Single Shot Multibox Detector”,《第 14 届欧洲计算机视觉会议论文集》1(2016):21–37。
³⁶ Shaoqing Ren 等人,“Faster R-CNN: Towards Real-Time Object Detection with Region Proposal Networks”,《第 28 届国际神经信息处理系统会议论文集》1(2015):91–99。
³⁷ Mingxing Tan 等人,“EfficientDet: Scalable and Efficient Object Detection”,arXiv 预印本 arXiv:1911.09070(2019)。
³⁸ Nicolai Wojke 等人,“Simple Online and Realtime Tracking with a Deep Association Metric”,arXiv 预印本 arXiv:1703.07402(2017)。
³⁹ 这种类型的层有时被称为反卷积层,但它不执行数学家所说的反卷积,因此应避免使用这个名称。
⁴⁰ Kaiming He 等人,“Mask R-CNN”,arXiv 预印本 arXiv:1703.06870(2017)。
第十五章:使用 RNNs 和 CNNs 处理序列
预测未来是你经常做的事情,无论是在结束朋友的句子还是预期早餐时咖啡的味道。在本章中,我们将讨论循环神经网络(RNNs)-一类可以预测未来的网络(嗯,至少在一定程度上)。RNNs 可以分析时间序列数据,例如您网站上每日活跃用户的数量,您所在城市的每小时温度,您家每日的用电量,附近汽车的轨迹等等。一旦 RNN 学习了数据中的过去模式,它就能利用自己的知识来预测未来,当然前提是过去的模式在未来仍然成立。
更一般地说,RNNs 可以处理任意长度的序列,而不是固定大小的输入。例如,它们可以将句子、文档或音频样本作为输入,使它们非常适用于自然语言处理应用,如自动翻译或语音转文本。
在本章中,我们将首先介绍 RNNs 的基本概念以及如何使用时间反向传播来训练它们。然后,我们将使用它们来预测时间序列。在此过程中,我们将研究常用的 ARMA 模型系列,通常用于预测时间序列,并将它们用作与我们的 RNNs 进行比较的基准。之后,我们将探讨 RNNs 面临的两个主要困难:
RNNs 并不是处理序列数据的唯一类型的神经网络。对于小序列,常规的密集网络可以胜任,而对于非常长的序列,例如音频样本或文本,卷积神经网络也可以表现得相当不错。我们将讨论这两种可能性,并通过实现 WaveNet 来结束本章-一种能够处理数万个时间步的 CNN 架构。让我们开始吧!
循环神经元和层
到目前为止,我们已经专注于前馈神经网络,其中激活仅在一个方向中流动,从输入层到输出层。循环神经网络看起来非常像前馈神经网络,只是它还有指向后方的连接。
让我们看看最简单的 RNN,由一个神经元组成,接收输入,产生输出,并将该输出发送回自身,如图 15-1(左)所示。在每个时间步 t(也称为帧),这个循环神经元接收输入x[(t)]以及来自上一个时间步的自己的输出ŷ[(t–1)]。由于在第一个时间步没有先前的输出,通常将其设置为 0。我们可以沿着时间轴表示这个小网络,如图 15-1(右)所示。这被称为将网络展开到时间轴(每个时间步表示一个循环神经元)。
![mls3 1501]()
图 15-1. 一个循环神经元(左)在时间轴上展开(右)
您可以轻松创建一个循环神经元层。在每个时间步t,每个神经元都接收来自输入向量x[(t)]和上一个时间步的输出向量ŷ[(t–1)],如图 15-2 所示。请注意,现在输入和输出都是向量(当只有一个神经元时,输出是标量)。
![mls3 1502]()
图 15-2. 一个循环神经元层(左)在时间轴上展开(右)
每个递归神经元有两组权重:一组用于输入x[(t)],另一组用于上一个时间步的输出ŷ[(t–1)]。让我们称这些权重向量为w[x]和w[ŷ]。如果我们考虑整个递归层而不仅仅是一个递归神经元,我们可以将所有权重向量放入两个权重矩阵:W[x]和W[ŷ]。
整个递归层的输出向量可以按照你所期望的方式计算,如方程 15-1 所示,其中b是偏置向量,ϕ(·)是激活函数(例如,ReLU¹)。
方程 15-1. 单个实例的递归层输出
就像前馈神经网络一样,我们可以通过将时间步t的所有输入放入输入矩阵X[(t)](参见方程 15-2)来一次性计算整个小批量的递归层输出。
方程 15-2. 一次传递中递归神经元层的所有实例的输出:[小批量
在这个方程中:
-
Ŷ[(t)]是一个m×n[neurons]矩阵,包含小批量中每个实例在时间步t的层输出(m是小批量中实例的数量,n[neurons]是神经元的数量)。
-
X[(t)]是一个m×n[inputs]矩阵,包含所有实例的输入(n[inputs]是输入特征的数量)。
-
W[x]是一个n[inputs]×n[neurons]矩阵,包含当前时间步输入的连接权重。
-
W[ŷ]是一个n[neurons]×n[neurons]矩阵,包含上一个时间步输出的连接权重。
-
b是一个大小为n[neurons]的向量,包含每个神经元的偏置项。
-
权重矩阵W[x]和W[ŷ]通常垂直连接成一个形状为(n[inputs] + n[neurons]) × n[neurons]的单个权重矩阵W(参见方程 15-2 的第二行)。
-
符号[X[(t)] Ŷ[(t–1)]]表示矩阵X[(t)]和Ŷ[(t–1)]的水平连接。
注意,Ŷ[(t)]是X[(t)]和Ŷ[(t–1)]的函数,X[(t–1)]和Ŷ[(t–2)]的函数,X[(t–2)]和Ŷ[(t–3)]的函数,依此类推。这使得Ŷ[(t)]是自时间t=0(即X[(0)], X[(1)], …, X[(t)])以来所有输入的函数。在第一个时间步骤,t=0 时,没有先前的输出,因此通常假定它们都是零。
记忆单元
由于递归神经元在时间步骤t的输出是前几个时间步骤的所有输入的函数,因此可以说它具有一种记忆形式。在时间步骤之间保留一些状态的神经网络的一部分称为记忆单元(或简称单元)。单个递归神经元或一层递归神经元是一个非常基本的单元,只能学习短模式(通常约为 10 个步骤长,但这取决于任务)。在本章后面,我们将看一些更复杂和强大的单元类型,能够学习更长的模式(大约长 10 倍,但这也取决于任务)。
时间步骤t时的单元状态,表示为h[(t)](“h”代表“隐藏”),是该时间步骤的一些输入和上一个时间步骤的状态的函数:h[(t)] = f(x[(t)], h[(t–1)])。在时间步骤t的输出,表示为ŷ[(t)],也是前一个状态和当前输入的函数。在我们迄今讨论的基本单元的情况下,输出只等于状态,但在更复杂的单元中,情况并非总是如此,如图 15-3 所示。
![mls3 1503]()
图 15-3。单元的隐藏状态和输出可能不同
输入和输出序列
RNN 可以同时接受一系列输入并产生一系列输出(参见图 15-4 左上方的网络)。这种序列到序列网络对于预测时间序列非常有用,例如您家每天的用电量:您向其提供过去N天的数据,并训练它输出将来一天的用电量(即从N – 1 天前到明天)。
或者,您可以向网络提供一系列输入并忽略除最后一个之外的所有输出(参见图 15-4 右上方的网络)。这是一个序列到向量网络。例如,您可以向网络提供与电影评论相对应的一系列单词,网络将输出情感分数(例如,从 0 [讨厌]到 1 [喜爱])。
相反,您可以在每个时间步骤反复向网络提供相同的输入向量,并让它输出一个序列(参见图 15-4 左下方的网络)。这是一个向量到序列网络。例如,输入可以是一幅图像(或 CNN 的输出),输出可以是该图像的标题。
最后,您可以有一个序列到向量网络,称为编码器,后面是一个向量到序列网络,称为解码器(参见图 15-4 的右下方网络)。例如,这可以用于将一种语言的句子翻译成另一种语言。您将向网络提供一种语言的句子,编码器将把这个句子转换成一个单一的向量表示,然后解码器将把这个向量解码成另一种语言的句子。这种两步模型,称为编码器-解码器²比尝试使用单个序列到序列的 RNN 实时翻译要好得多(就像左上角表示的那种):一个句子的最后几个词可能会影响翻译的前几个词,因此您需要等到看完整个句子后再进行翻译。我们将在第十六章中介绍编码器-解码器的实现(正如您将看到的,它比图 15-4 所暗示的要复杂一些)。
![mls3 1504]()
图 15-4. 序列到序列(左上)、序列到向量(右上)、向量到序列(左下)和编码器-解码器(右下)网络
这种多功能性听起来很有前途,但如何训练循环神经网络呢?
训练 RNNs
要训练 RNN,关键是将其通过时间展开(就像我们刚刚做的那样),然后使用常规的反向传播(参见图 15-5)。这种策略称为通过时间的反向传播(BPTT)。
就像常规反向传播一样,首先通过展开的网络进行第一次前向传递(由虚线箭头表示)。然后使用损失函数ℒ(Y[(0)], Y[(1)], …, Y[(T)]; Ŷ[(0)], Ŷ[(1)], …, Ŷ[(T)])评估输出序列(其中Y[(i)]是第i个目标,Ŷ[(i)]是第i个预测,T是最大时间步长)。请注意,此损失函数可能会忽略一些输出。例如,在序列到向量的 RNN 中,除了最后一个输出之外,所有输出都会被忽略。在图 15-5 中,损失函数仅基于最后三个输出计算。然后,该损失函数的梯度通过展开的网络向后传播(由实线箭头表示)。在这个例子中,由于输出Ŷ[(0)]和Ŷ[(1)]没有用于计算损失,梯度不会通过它们向后传播;它们只会通过Ŷ[(2)]、Ŷ[(3)]和Ŷ[(4)]向后传播。此外,由于在每个时间步骤中使用相同的参数W和b,它们的梯度将在反向传播过程中被多次调整。一旦反向阶段完成并计算出所有梯度,BPTT 可以执行梯度下降步骤来更新参数(这与常规反向传播没有区别)。
![mls3 1505]()
图 15-5. 通过时间反向传播
幸运的是,Keras 会为您处理所有这些复杂性,您将看到。但在我们到达那里之前,让我们加载一个时间序列,并开始使用传统工具进行分析,以更好地了解我们正在处理的内容,并获得一些基准指标。
预测时间序列
好了!假设您刚被芝加哥交通管理局聘为数据科学家。您的第一个任务是构建一个能够预测明天公交和轨道乘客数量的模型。您可以访问自 2001 年以来的日常乘客数据。让我们一起看看您将如何处理这个问题。我们将从加载和清理数据开始:
import pandas as pd
from pathlib import Path
path = Path("datasets/ridership/CTA_-_Ridership_-_Daily_Boarding_Totals.csv")
df = pd.read_csv(path, parse_dates=["service_date"])
df.columns = ["date", "day_type", "bus", "rail", "total"] # shorter names
df = df.sort_values("date").set_index("date")
df = df.drop("total", axis=1) # no need for total, it's just bus + rail
df = df.drop_duplicates() # remove duplicated months (2011-10 and 2014-07)
我们加载 CSV 文件,设置短列名,按日期对行进行排序,删除多余的total列,并删除重复行。现在让我们看看前几行是什么样子的:
>>> df.head()
day_type bus rail
date
2001-01-01 U 297192 126455
2001-01-02 W 780827 501952
2001-01-03 W 824923 536432
2001-01-04 W 870021 550011
2001-01-05 W 890426 557917
在 2001 年 1 月 1 日,芝加哥有 297,192 人乘坐公交车,126,455 人乘坐火车。day_type列包含W表示工作日,A表示周六,U表示周日或假期。
现在让我们绘制 2019 年几个月的公交和火车乘客量数据,看看它是什么样子的(参见图 15-6):
import matplotlib.pyplot as plt
df["2019-03":"2019-05"].plot(grid=True, marker=".", figsize=(8, 3.5))
plt.show()
![mls3 1506]()
图 15-6。芝加哥的日常乘客量
请注意,Pandas 在范围中包括起始月份和结束月份,因此这将绘制从 3 月 1 日到 5 月 31 日的数据。这是一个时间序列:在不同时间步长上具有值的数据,通常在规则间隔上。更具体地说,由于每个时间步长有多个值,因此称为多变量时间序列。如果我们只看bus列,那将是一个单变量时间序列,每个时间步长有一个值。在处理时间序列时,预测未来值(即预测)是最典型的任务,这也是我们将在本章中重点关注的内容。其他任务包括插补(填补缺失的过去值)、分类、异常检测等。
查看图 15-6,我们可以看到每周明显重复的类似模式。这被称为每周季节性。实际上,在这种情况下,季节性非常强,通过简单地复制一周前的值来预测明天的乘客量将产生相当不错的结果。这被称为天真预测:简单地复制过去的值来进行预测。天真预测通常是一个很好的基准,有时甚至在某些情况下很难超越。
注意
一般来说,天真预测意味着复制最新已知值(例如,预测明天与今天相同)。然而,在我们的情况下,复制上周的值效果更好,因为存在强烈的每周季节性。
为了可视化这些天真预测,让我们将两个时间序列(公交和火车)以及相同时间序列向右移动一周(即向右移动)的时间序列叠加使用虚线。我们还将绘制两者之间的差异(即时间t处的值减去时间t - 7 处的值);这称为差分(参见图 15-7):
diff_7 = df[["bus", "rail"]].diff(7)["2019-03":"2019-05"]
fig, axs = plt.subplots(2, 1, sharex=True, figsize=(8, 5))
df.plot(ax=axs[0], legend=False, marker=".") # original time series
df.shift(7).plot(ax=axs[0], grid=True, legend=False, linestyle=":") # lagged
diff_7.plot(ax=axs[1], grid=True, marker=".") # 7-day difference time series
plt.show()
不错!注意滞后时间序列如何紧密跟踪实际时间序列。当一个时间序列与其滞后版本相关联时,我们说该时间序列是自相关的。正如您所看到的,大多数差异都相当小,除了五月底。也许那时有一个假期?让我们检查day_type列:
>>> list(df.loc["2019-05-25":"2019-05-27"]["day_type"])
['A', 'U', 'U']
![mls3 1507]()
图 15-7。与 7 天滞后时间序列叠加的时间序列(顶部),以及t和t - 7 之间的差异(底部)
事实上,那时有一个长周末:周一是阵亡将士纪念日假期。我们可以使用这一列来改进我们的预测,但现在让我们只测量我们任意关注的三个月期间(2019 年 3 月、4 月和 5 月)的平均绝对误差,以获得一个大致的概念:
>>> diff_7.abs().mean()
bus 43915.608696
rail 42143.271739
dtype: float64
我们的天真预测得到大约 43,916 名公交乘客和约 42,143 名火车乘客的 MAE。一眼看去很难判断这是好是坏,所以让我们将预测误差放入透视中,通过将它们除以目标值来进行评估:
>>> targets = df[["bus", "rail"]]["2019-03":"2019-05"]
>>> (diff_7 / targets).abs().mean()
bus 0.082938
rail 0.089948
dtype: float64
我们刚刚计算的是平均绝对百分比误差(MAPE):看起来我们的天真预测为公交大约为 8.3%,火车为 9.0%。有趣的是,火车预测的 MAE 看起来比公交预测的稍好一些,而 MAPE 则相反。这是因为公交乘客量比火车乘客量大,因此自然预测误差也更大,但当我们将误差放入透视时,结果表明公交预测实际上略优于火车预测。
提示
MAE、MAPE 和 MSE 是评估预测的最常见指标之一。与往常一样,选择正确的指标取决于任务。例如,如果您的项目对大误差的影响是小误差的平方倍,那么 MSE 可能更可取,因为它会严厉惩罚大误差。
观察时间序列,似乎没有明显的月度季节性,但让我们检查一下是否存在年度季节性。我们将查看 2001 年至 2019 年的数据。为了减少数据窥探的风险,我们暂时忽略更近期的数据。让我们为每个系列绘制一个 12 个月的滚动平均线,以可视化长期趋势(参见图 15-8):
period = slice("2001", "2019")
df_monthly = df.resample('M').mean() # compute the mean for each month
rolling_average_12_months = df_monthly[period].rolling(window=12).mean()
fig, ax = plt.subplots(figsize=(8, 4))
df_monthly[period].plot(ax=ax, marker=".")
rolling_average_12_months.plot(ax=ax, grid=True, legend=False)
plt.show()
![mls3 1508]()
图 15-8。年度季节性和长期趋势
是的!确实存在一些年度季节性,尽管比每周季节性更嘈杂,对于铁路系列而言更为明显,而不是公交系列:我们看到每年大致相同日期出现高峰和低谷。让我们看看如果绘制 12 个月的差分会得到什么(参见图 15-9):
df_monthly.diff(12)[period].plot(grid=True, marker=".", figsize=(8, 3))
plt.show()
![mls3 1509]()
图 15-9。12 个月的差分
注意,差分不仅消除了年度季节性,还消除了长期趋势。例如,2016 年至 2019 年时间序列中存在的线性下降趋势在差分时间序列中变为大致恒定的负值。事实上,差分是一种常用的技术,用于消除时间序列中的趋势和季节性:研究平稳时间序列更容易,这意味着其统计特性随时间保持不变,没有任何季节性或趋势。一旦您能够对差分时间序列进行准确的预测,只需将先前减去的过去值添加回来,就可以将其转换为实际时间序列的预测。
您可能会认为我们只是试图预测明天的乘客量,因此长期模式比短期模式更不重要。您是对的,但是,通过考虑长期模式,我们可能能够稍微提高性能。例如,2017 年 10 月,每日公交乘客量减少了约 2500 人,这代表每周减少约 570 名乘客,因此如果我们处于 2017 年 10 月底,通过从上周复制数值,减去 570,来预测明天的乘客量是有道理的。考虑趋势将使您的平均预测略微更准确。
现在您熟悉了乘客量时间序列,以及时间序列分析中一些最重要的概念,包括季节性、趋势、差分和移动平均,让我们快速看一下一个非常流行的统计模型家族,通常用于分析时间序列。
ARMA 模型家族
我们将从上世纪 30 年代由赫尔曼·沃尔德(Herman Wold)开发的自回归移动平均(ARMA)模型开始:它通过对滞后值的简单加权和添加移动平均来计算其预测,非常类似我们刚刚讨论的。具体来说,移动平均分量是通过最近几个预测误差的加权和来计算的。方程 15-3 展示了该模型如何进行预测。
第 15-3 方程。使用 ARMA 模型进行预测
在这个方程中:
重要的是,这个模型假设时间序列是平稳的。如果不是,那么差分可能有所帮助。在一个时间步上使用差分将产生时间序列的导数的近似值:实际上,它将给出每个时间步的系列斜率。这意味着它将消除任何线性趋势,将其转换为一个常数值。例如,如果你对系列[3, 5, 7, 9, 11]应用一步差分,你会得到差分系列[2, 2, 2, 2]。
如果原始时间序列具有二次趋势而不是线性趋势,那么一轮差分将不足够。例如,系列[1, 4, 9, 16, 25, 36]经过一轮差分后变为[3, 5, 7, 9, 11],但如果你再进行第二轮差分,你会得到[2, 2, 2, 2]。因此,进行两轮差分将消除二次趋势。更一般地,连续运行d轮差分计算时间序列的d阶导数的近似值,因此它将消除多项式趋势直到d阶。这个超参数d被称为积分阶数。
差分是 1970 年由乔治·博克斯和格威林·詹金斯在他们的书《时间序列分析》(Wiley)中介绍的自回归积分移动平均(ARIMA)模型的核心贡献:这个模型运行d轮差分使时间序列更平稳,然后应用常规 ARMA 模型。在进行预测时,它使用这个 ARMA 模型,然后将差分减去的项加回来。
ARMA 家族的最后一个成员是季节性 ARIMA(SARIMA)模型:它以与 ARIMA 相同的方式对时间序列建模,但另外还为给定频率(例如每周)建模一个季节性组件,使用完全相同的 ARIMA 方法。它总共有七个超参数:与 ARIMA 相同的p、d和q超参数,再加上额外的P、D和Q超参数来建模季节性模式,最后是季节性模式的周期,标记为s。超参数P、D和Q就像p、d和q一样,但它们用于模拟时间序列在t – s、t – 2s、t – 3s等时刻。
让我们看看如何将 SARIMA 模型拟合到铁路时间序列,并用它来预测明天的乘客量。我们假设今天是 2019 年 5 月的最后一天,我们想要预测“明天”,也就是 2019 年 6 月 1 日的铁路乘客量。为此,我们可以使用statsmodels库,其中包含许多不同的统计模型,包括由ARIMA类实现的 ARMA 模型及其变体:
from statsmodels.tsa.arima.model import ARIMA
origin, today = "2019-01-01", "2019-05-31"
rail_series = df.loc[origin:today]["rail"].asfreq("D")
model = ARIMA(rail_series,
order=(1, 0, 0),
seasonal_order=(0, 1, 1, 7))
model = model.fit()
y_pred = model.forecast() # returns 427,758.6
在这个代码示例中:
-
我们首先导入ARIMA类,然后我们从 2019 年初开始到“今天”获取铁路乘客数据,并使用asfreq("D")将时间序列的频率设置为每天:在这种情况下,这不会改变数据,因为它已经是每天的,但如果没有这个,ARIMA类将不得不猜测频率,并显示警告。
-
接下来,我们创建一个ARIMA实例,将所有数据传递到“今天”,并设置模型超参数:order=(1, 0, 0)表示p=1,d=0,q=0,seasonal_order=(0, 1, 1, 7)表示P=0,D=1,Q=1,s=7。请注意,statsmodels API 与 Scikit-Learn 的 API 有些不同,因为我们在构建时将数据传递给模型,而不是将数据传递给fit()方法。
-
接下来,我们拟合模型,并用它为“明天”,也就是 2019 年 6 月 1 日,做出预测。
预测为 427,759 名乘客,而实际上有 379,044 名。哎呀,我们偏差 12.9%——这相当糟糕。实际上,这比天真预测稍微糟糕,天真预测为 426,932,偏差为 12.6%。但也许那天我们只是运气不好?为了检查这一点,我们可以在循环中运行相同的代码,为三月、四月和五月的每一天进行预测,并计算该期间的平均绝对误差:
origin, start_date, end_date = "2019-01-01", "2019-03-01", "2019-05-31"
time_period = pd.date_range(start_date, end_date)
rail_series = df.loc[origin:end_date]["rail"].asfreq("D")
y_preds = []
for today in time_period.shift(-1):
model = ARIMA(rail_series[origin:today], # train on data up to "today"
order=(1, 0, 0),
seasonal_order=(0, 1, 1, 7))
model = model.fit() # note that we retrain the model every day!
y_pred = model.forecast()[0]
y_preds.append(y_pred)
y_preds = pd.Series(y_preds, index=time_period)
mae = (y_preds - rail_series[time_period]).abs().mean() # returns 32,040.7
啊,好多了!平均绝对误差约为 32,041,比我们用天真预测得到的平均绝对误差(42,143)显著低。因此,虽然模型并不完美,但平均而言仍然远远超过天真预测。
此时,您可能想知道如何为 SARIMA 模型选择良好的超参数。有几种方法,但最简单的方法是粗暴的方法:进行网格搜索。对于要评估的每个模型(即每个超参数组合),您可以运行前面的代码示例,仅更改超参数值。通常p、q、P和Q值较小(通常为 0 到 2,有时可达 5 或 6),d和D通常为 0 或 1,有时为 2。至于s,它只是主要季节模式的周期:在我们的情况下是 7,因为有强烈的每周季节性。具有最低平均绝对误差的模型获胜。当然,如果它更符合您的业务目标,您可以用另一个指标替换平均绝对误差。就是这样!
为机器学习模型准备数据
现在我们有了两个基线,天真预测和 SARIMA,让我们尝试使用迄今为止涵盖的机器学习模型来预测这个时间序列,首先从基本的线性模型开始。我们的目标是根据过去 8 周(56 天)的数据来预测明天的乘客量。因此,我们模型的输入将是序列(通常是生产中的每天一个序列),每个序列包含从时间步t - 55 到t的 56 个值。对于每个输入序列,模型将输出一个值:时间步t + 1 的预测。
但我们将使用什么作为训练数据呢?嗯,这就是诀窍:我们将使用过去的每个 56 天窗口作为训练数据,每个窗口的目标将是紧随其后的值。
Keras 实际上有一个很好的实用函数称为tf.keras.utils.timeseries_dataset_from_array(),帮助我们准备训练集。它以时间序列作为输入,并构建一个 tf.data.Dataset(在第十三章中介绍)包含所需长度的所有窗口,以及它们对应的目标。以下是一个示例,它以包含数字 0 到 5 的时间序列为输入,并创建一个包含所有长度为 3 的窗口及其对应目标的数据集,分组成大小为 2 的批次:
import tensorflow as tf
my_series = [0, 1, 2, 3, 4, 5]
my_dataset = tf.keras.utils.timeseries_dataset_from_array(
my_series,
targets=my_series[3:], # the targets are 3 steps into the future
sequence_length=3,
batch_size=2
)
让我们检查一下这个数据集的内容:
>>> list(my_dataset)
[(<tf.Tensor: shape=(2, 3), dtype=int32, numpy=
array([[0, 1, 2],
[1, 2, 3]], dtype=int32)>,
<tf.Tensor: shape=(2,), dtype=int32, numpy=array([3, 4], dtype=int32)>),
(<tf.Tensor: shape=(1, 3), dtype=int32, numpy=array([[2, 3, 4]], dtype=int32)>,
<tf.Tensor: shape=(1,), dtype=int32, numpy=array([5], dtype=int32)>)]
数据集中的每个样本是长度为 3 的窗口,以及其对应的目标(即窗口后面的值)。窗口是[0, 1, 2],[1, 2, 3]和[2, 3, 4],它们各自的目标是 3,4 和 5。由于总共有三个窗口,不是批次大小的倍数,最后一个批次只包含一个窗口而不是两个。
另一种获得相同结果的方法是使用 tf.data 的Dataset类的window()方法。这更复杂,但它给了您完全的控制,这将在本章后面派上用场,让我们看看它是如何工作的。window()方法返回一个窗口数据集的数据集:
>>> for window_dataset in tf.data.Dataset.range(6).window(4, shift=1):
... for element in window_dataset:
... print(f"{element}", end=" ")
... print()
...
0 1 2 3
1 2 3 4
2 3 4 5
3 4 5
4 5
5
在这个例子中,数据集包含六个窗口,每个窗口相对于前一个窗口向前移动一个步骤,最后三个窗口较小,因为它们已经到达系列的末尾。通常情况下,您会希望通过向window()方法传递drop_remainder=True来摆脱这些较小的窗口。
window()方法返回一个嵌套数据集,类似于一个列表的列表。当您想要通过调用其数据集方法(例如,对它们进行洗牌或分批处理)来转换每个窗口时,这将非常有用。然而,我们不能直接使用嵌套数据集进行训练,因为我们的模型将期望张量作为输入,而不是数据集。
因此,我们必须调用flat_map()方法:它将嵌套数据集转换为平坦数据集(包含张量而不是数据集)。例如,假设{1, 2, 3}表示包含张量 1、2 和 3 序列的数据集。如果展平嵌套数据集{{1, 2}, {3, 4, 5, 6}},您将得到平坦数据集{1, 2, 3, 4, 5, 6}。
此外,flat_map()方法接受一个函数作为参数,允许您在展平之前转换嵌套数据集中的每个数据集。例如,如果您将函数lambda ds: ds.batch(2)传递给flat_map(),那么它将把嵌套数据集{{1, 2}, {3, 4, 5, 6}}转换为平坦数据集{[1, 2], [3, 4], [5, 6]}:这是一个包含 3 个大小为 2 的张量的数据集。
考虑到这一点,我们准备对数据集进行展平处理:
>>> dataset = tf.data.Dataset.range(6).window(4, shift=1, drop_remainder=True)
>>> dataset = dataset.flat_map(lambda window_dataset: window_dataset.batch(4))
>>> for window_tensor in dataset:
... print(f"{window_tensor}")
...
[0 1 2 3]
[1 2 3 4]
[2 3 4 5]
由于每个窗口数据集恰好包含四个项目,对窗口调用batch(4)会产生一个大小为 4 的单个张量。太棒了!现在我们有一个包含连续窗口的数据集,表示为张量。让我们创建一个小助手函数,以便更容易地从数据集中提取窗口:
def to_windows(dataset, length):
dataset = dataset.window(length, shift=1, drop_remainder=True)
return dataset.flat_map(lambda window_ds: window_ds.batch(length))
最后一步是使用map()方法将每个窗口拆分为输入和目标。我们还可以将生成的窗口分组成大小为 2 的批次:
>>> dataset = to_windows(tf.data.Dataset.range(6), 4) # 3 inputs + 1 target = 4
>>> dataset = dataset.map(lambda window: (window[:-1], window[-1]))
>>> list(dataset.batch(2))
[(<tf.Tensor: shape=(2, 3), dtype=int64, numpy=
array([[0, 1, 2],
[1, 2, 3]])>,
<tf.Tensor: shape=(2,), dtype=int64, numpy=array([3, 4])>),
(<tf.Tensor: shape=(1, 3), dtype=int64, numpy=array([[2, 3, 4]])>,
<tf.Tensor: shape=(1,), dtype=int64, numpy=array([5])>)]
正如您所看到的,我们现在得到了与之前使用timeseries_dataset_from_array()函数相同的输出(稍微费劲一些,但很快就会值得)。
现在,在开始训练之前,我们需要将数据分为训练期、验证期和测试期。我们现在将专注于铁路乘客量。我们还将通过一百万分之一的比例缩小它,以确保值接近 0-1 范围;这与默认的权重初始化和学习率很好地配合:
rail_train = df["rail"]["2016-01":"2018-12"] / 1e6
rail_valid = df["rail"]["2019-01":"2019-05"] / 1e6
rail_test = df["rail"]["2019-06":] / 1e6
注意
处理时间序列时,通常希望按时间划分。但在某些情况下,您可能能够沿其他维度划分,这将使您有更长的时间段进行训练。例如,如果您有关于 2001 年至 2019 年间 10,000 家公司财务状况的数据,您可能能够将这些数据分割到不同的公司。然而,很可能这些公司中的许多将强相关(例如,整个经济部门可能一起上涨或下跌),如果在训练集和测试集中有相关的公司,那么您的测试集将不会那么有用,因为其泛化误差的度量将是乐观偏倚的。
接下来,让我们使用timeseries_dataset_from_array()为训练和验证创建数据集。由于梯度下降期望训练集中的实例是独立同分布的(IID),正如我们在第四章中看到的那样,我们必须设置参数shuffle=True来对训练窗口进行洗牌(但不洗牌其中的内容):
seq_length = 56
train_ds = tf.keras.utils.timeseries_dataset_from_array(
rail_train.to_numpy(),
targets=rail_train[seq_length:],
sequence_length=seq_length,
batch_size=32,
shuffle=True,
seed=42
)
valid_ds = tf.keras.utils.timeseries_dataset_from_array(
rail_valid.to_numpy(),
targets=rail_valid[seq_length:],
sequence_length=seq_length,
batch_size=32
)
现在我们已经准备好构建和训练任何回归模型了!
使用线性模型进行预测
让我们首先尝试一个基本的线性模型。我们将使用 Huber 损失,通常比直接最小化 MAE 效果更好,如第十章中讨论的那样。我们还将使用提前停止:
tf.random.set_seed(42)
model = tf.keras.Sequential([
tf.keras.layers.Dense(1, input_shape=[seq_length])
])
early_stopping_cb = tf.keras.callbacks.EarlyStopping(
monitor="val_mae", patience=50, restore_best_weights=True)
opt = tf.keras.optimizers.SGD(learning_rate=0.02, momentum=0.9)
model.compile(loss=tf.keras.losses.Huber(), optimizer=opt, metrics=["mae"])
history = model.fit(train_ds, validation_data=valid_ds, epochs=500,
callbacks=[early_stopping_cb])
该模型达到了约 37,866 的验证 MAE(结果可能有所不同)。这比天真的预测要好,但比 SARIMA 模型要差。⁵
我们能用 RNN 做得更好吗?让我们看看!
使用简单 RNN 进行预测
让我们尝试最基本的 RNN,其中包含一个具有一个循环神经元的单个循环层,就像我们在图 15-1 中看到的那样:
model = tf.keras.Sequential([
tf.keras.layers.SimpleRNN(1, input_shape=[None, 1])
])
Keras 中的所有循环层都期望形状为[批量大小,时间步长,维度]的 3D 输入,其中维度对于单变量时间序列为 1,对于多变量时间序列为更多。请记住,input_shape参数忽略第一个维度(即批量大小),由于循环层可以接受任意长度的输入序列,因此我们可以将第二个维度设置为None,表示“任意大小”。最后,由于我们处理的是单变量时间序列,我们需要最后一个维度的大小为 1。这就是为什么我们指定输入形状为[None, 1]:它表示“任意长度的单变量序列”。请注意,数据集实际上包含形状为[批量大小,时间步长]的输入,因此我们缺少最后一个维度,大小为 1,但在这种情况下,Keras 很友好地为我们添加了它。
这个模型的工作方式与我们之前看到的完全相同:初始状态h[(init)]设置为 0,并传递给一个单个的循环神经元,以及第一个时间步的值x[(0)]。神经元计算这些值加上偏置项的加权和,并使用默认的双曲正切函数对结果应用激活函数。结果是第一个输出y[0]。在简单 RNN 中,这个输出也是新状态h[0]。这个新状态传递给相同的循环神经元,以及下一个输入值x[(1)],并且这个过程重复直到最后一个时间步。最后,该层只输出最后一个值:在我们的情况下,序列长度为 56 步,因此最后一个值是y[55]。所有这些都同时为批次中的每个序列执行,本例中有 32 个序列。
注意
默认情况下,Keras 中的循环层只返回最终输出。要使它们返回每个时间步的一个输出,您必须设置return_sequences=True,如您将看到的。
这就是我们的第一个循环模型!这是一个序列到向量的模型。由于只有一个输出神经元,输出向量的大小为 1。
现在,如果您编译、训练和评估这个模型,就像之前的模型一样,您会发现它一点用也没有:其验证 MAE 大于 100,000!哎呀。这是可以预料到的,有两个原因:
-
该模型只有一个循环神经元,因此在每个时间步进行预测时,它只能使用当前时间步的输入值和上一个时间步的输出值。这不足以进行预测!换句话说,RNN 的记忆极为有限:只是一个数字,它的先前输出。让我们来数一下这个模型有多少参数:由于只有一个循环神经元,只有两个输入值,整个模型只有三个参数(两个权重加上一个偏置项)。这对于这个时间序列来说远远不够。相比之下,我们之前的模型可以一次查看所有 56 个先前的值,并且总共有 57 个参数。
-
时间序列包含的值从 0 到约 1.4,但由于默认激活函数是 tanh,循环层只能输出-1 到+1 之间的值。它无法预测 1.0 到 1.4 之间的值。
让我们解决这两个问题:我们将创建一个具有更大的循环层的模型,其中包含 32 个循环神经元,并在其顶部添加一个密集的输出层,其中只有一个输出神经元,没有激活函数。循环层将能够在一个时间步到下一个时间步传递更多信息,而密集输出层将把最终输出从 32 维投影到 1 维,没有任何值范围约束:
univar_model = tf.keras.Sequential([
tf.keras.layers.SimpleRNN(32, input_shape=[None, 1]),
tf.keras.layers.Dense(1) # no activation function by default
])
现在,如果您像之前那样编译、拟合和评估这个模型,您会发现其验证 MAE 达到了 27,703。这是迄今为止我们训练过的最佳模型,甚至击败了 SARIMA 模型:我们做得相当不错!
提示
我们只对时间序列进行了归一化,没有去除趋势和季节性,但模型仍然表现良好。这很方便,因为这样可以快速搜索有前途的模型,而不用太担心预处理。然而,为了获得最佳性能,您可能希望尝试使时间序列更加平稳;例如,使用差分。
使用深度 RNN 进行预测
通常会堆叠多层单元,如图 15-10 所示。这给你一个深度 RNN。
![mls3 1510]()
图 15-10. 深度 RNN(左)在时间轴上展开(右)
使用 Keras 实现深度 RNN 很简单:只需堆叠循环层。在下面的示例中,我们使用三个SimpleRNN层(但我们也可以使用任何其他类型的循环层,如LSTM层或GRU层,我们将很快讨论)。前两个是序列到序列层,最后一个是序列到向量层。最后,Dense层生成模型的预测(您可以将其视为向量到向量层)。因此,这个模型就像图 15-10 中表示的模型一样,只是忽略了Ŷ[(0)]到Ŷ[(t–1_)]的输出,并且在Ŷ[(t)]之上有一个密集层,输出实际预测:
deep_model = tf.keras.Sequential([
tf.keras.layers.SimpleRNN(32, return_sequences=True, input_shape=[None, 1]),
tf.keras.layers.SimpleRNN(32, return_sequences=True),
tf.keras.layers.SimpleRNN(32),
tf.keras.layers.Dense(1)
])
警告
确保对所有循环层设置return_sequences=True(除非您只关心最后的输出,最后一个循环层除外)。如果您忘记为一个循环层设置此参数,它将输出一个 2D 数组,其中仅包含最后一个时间步的输出,而不是包含所有时间步输出的 3D 数组。下一个循环层将抱怨您没有以预期的 3D 格式提供序列。
如果您训练和评估这个模型,您会发现它的 MAE 约为 31,211。这比两个基线都要好,但它并没有击败我们的“更浅”的 RNN。看起来这个 RNN 对我们的任务来说有点太大了。
多变量时间序列预测
神经网络的一个很大的优点是它们的灵活性:特别是,它们几乎不需要改变架构就可以处理多变量时间序列。例如,让我们尝试使用公交和铁路数据作为输入来预测铁路时间序列。事实上,让我们也加入日期类型!由于我们总是可以提前知道明天是工作日、周末还是假日,我们可以将日期类型系列向未来推移一天,这样模型就会将明天的日期类型作为输入。为简单起见,我们将使用 Pandas 进行此处理:
df_mulvar = df[["bus", "rail"]] / 1e6 # use both bus & rail series as input
df_mulvar["next_day_type"] = df["day_type"].shift(-1) # we know tomorrow's type
df_mulvar = pd.get_dummies(df_mulvar) # one-hot encode the day type
现在 df_mulvar 是一个包含五列的 DataFrame:公交和铁路数据,以及包含下一天类型的独热编码的三列(请记住,有三种可能的日期类型,W、A 和 U)。接下来我们可以像之前一样继续。首先,我们将数据分为三个时期,用于训练、验证和测试:
mulvar_train = df_mulvar["2016-01":"2018-12"]
mulvar_valid = df_mulvar["2019-01":"2019-05"]
mulvar_test = df_mulvar["2019-06":]
然后我们创建数据集:
train_mulvar_ds = tf.keras.utils.timeseries_dataset_from_array(
mulvar_train.to_numpy(), # use all 5 columns as input
targets=mulvar_train["rail"][seq_length:], # forecast only the rail series
[...] # the other 4 arguments are the same as earlier
)
valid_mulvar_ds = tf.keras.utils.timeseries_dataset_from_array(
mulvar_valid.to_numpy(),
targets=mulvar_valid["rail"][seq_length:],
[...] # the other 2 arguments are the same as earlier
)
最后我们创建 RNN:
mulvar_model = tf.keras.Sequential([
tf.keras.layers.SimpleRNN(32, input_shape=[None, 5]),
tf.keras.layers.Dense(1)
])
请注意,与我们之前构建的 univar_model RNN 唯一的区别是输入形状:在每个时间步骤,模型现在接收五个输入,而不是一个。这个模型实际上达到了 22,062 的验证 MAE。现在我们取得了很大的进展!
事实上,让 RNN 预测公交和铁路乘客量并不太难。您只需要在创建数据集时更改目标,将其设置为训练集的 mulvar_train[["bus", "rail"]][seq_length:],验证集的 mulvar_valid[["bus", "rail"]][seq_length:]。您还必须在输出 Dense 层中添加一个额外的神经元,因为现在它必须进行两次预测:一次是明天的公交乘客量,另一次是铁路乘客量。就是这样!
正如我们在第十章中讨论的那样,对于多个相关任务使用单个模型通常比为每个任务使用单独的模型效果更好,因为为一个任务学习的特征可能对其他任务也有用,而且因为在多个任务中表现良好可以防止模型过拟合(这是一种正则化形式)。然而,这取决于任务,在这种特殊情况下,同时预测公交和铁路乘客量的多任务 RNN 并不像专门预测其中一个的模型表现得那么好(使用所有五列作为输入)。尽管如此,它对铁路的验证 MAE 达到了 25,330,对公交达到了 26,369,这还是相当不错的。
提前预测多个时间步
到目前为止,我们只预测了下一个时间步的值,但我们也可以通过适当更改目标来预测几个步骤之后的值(例如,要预测两周后的乘客量,我们只需将目标更改为比 1 天后提前 14 天的值)。但是如果我们想预测接下来的 14 个值呢?
第一种选择是取我们之前为铁路时间序列训练的 univar_model RNN,让它预测下一个值,并将该值添加到输入中,就好像预测的值实际上已经发生了;然后我们再次使用模型来预测下一个值,依此类推,如下面的代码所示:
import numpy as np
X = rail_valid.to_numpy()[np.newaxis, :seq_length, np.newaxis]
for step_ahead in range(14):
y_pred_one = univar_model.predict(X)
X = np.concatenate([X, y_pred_one.reshape(1, 1, 1)], axis=1)
在这段代码中,我们取验证期间前 56 天的铁路乘客量,并将数据转换为形状为 [1, 56, 1] 的 NumPy 数组(请记住,循环层期望 3D 输入)。然后我们重复使用模型来预测下一个值,并将每个预测附加到输入系列中,沿着时间轴(axis=1)。生成的预测在图 15-11 中绘制。
警告
如果模型在一个时间步骤上出现错误,那么接下来的时间步骤的预测也会受到影响:错误往往会累积。因此,最好只在少数步骤中使用这种技术。
![mls3 1511]()
图 15-11。提前 14 步预测,一次预测一步
第二个选项是训练一个 RNN 一次性预测接下来的 14 个值。我们仍然可以使用一个序列到向量模型,但它将输出 14 个值而不是 1。然而,我们首先需要改变目标,使其成为包含接下来 14 个值的向量。为此,我们可以再次使用timeseries_dataset_from_array(),但这次要求它创建没有目标(targets=None)的数据集,并且具有更长的序列,长度为seq_length + 14。然后我们可以使用数据集的map()方法对每个序列批次应用自定义函数,将其分成输入和目标。在这个例子中,我们使用多变量时间序列作为输入(使用所有五列),并预测未来 14 天的铁路乘客量。
def split_inputs_and_targets(mulvar_series, ahead=14, target_col=1):
return mulvar_series[:, :-ahead], mulvar_series[:, -ahead:, target_col]
ahead_train_ds = tf.keras.utils.timeseries_dataset_from_array(
mulvar_train.to_numpy(),
targets=None,
sequence_length=seq_length + 14,
[...] # the other 3 arguments are the same as earlier
).map(split_inputs_and_targets)
ahead_valid_ds = tf.keras.utils.timeseries_dataset_from_array(
mulvar_valid.to_numpy(),
targets=None,
sequence_length=seq_length + 14,
batch_size=32
).map(split_inputs_and_targets)
现在我们只需要将输出层的单元数从 1 增加到 14:
ahead_model = tf.keras.Sequential([
tf.keras.layers.SimpleRNN(32, input_shape=[None, 5]),
tf.keras.layers.Dense(14)
])
训练完这个模型后,你可以像这样一次性预测接下来的 14 个值:
X = mulvar_valid.to_numpy()[np.newaxis, :seq_length] # shape [1, 56, 5]
Y_pred = ahead_model.predict(X) # shape [1, 14]
这种方法效果相当不错。它对于第二天的预测显然比对未来 14 天的预测要好,但它不会像之前的方法那样累积误差。然而,我们仍然可以做得更好,使用一个序列到序列(或seq2seq)模型。
使用序列到序列模型进行预测
不是只在最后一个时间步训练模型来预测接下来的 14 个值,而是在每一个时间步都训练它来预测接下来的 14 个值。换句话说,我们可以将这个序列到向量的 RNN 转变为一个序列到序列的 RNN。这种技术的优势在于损失函数将包含 RNN 在每一个时间步的输出,而不仅仅是最后一个时间步的输出。
这意味着会有更多的误差梯度通过模型流动,它们不需要像以前那样通过时间流动,因为它们将来自每一个时间步的输出,而不仅仅是最后一个时间步。这将使训练更加稳定和快速。
明确一点,在时间步 0,模型将输出一个包含时间步 1 到 14 的预测的向量,然后在时间步 1,模型将预测时间步 2 到 15,依此类推。换句话说,目标是连续窗口的序列,每个时间步向后移动一个时间步。目标不再是一个向量,而是一个与输入相同长度的序列,每一步包含一个 14 维向量。
准备数据集并不是简单的,因为每个实例的输入是一个窗口,输出是窗口序列。一种方法是连续两次使用我们之前创建的to_windows()实用函数,以获得连续窗口的窗口。例如,让我们将数字 0 到 6 的系列转换为包含 4 个连续窗口的数据集,每个窗口长度为 3:
>>> my_series = tf.data.Dataset.range(7)
>>> dataset = to_windows(to_windows(my_series, 3), 4)
>>> list(dataset)
[<tf.Tensor: shape=(4, 3), dtype=int64, numpy=
array([[0, 1, 2],
[1, 2, 3],
[2, 3, 4],
[3, 4, 5]])>,
<tf.Tensor: shape=(4, 3), dtype=int64, numpy=
array([[1, 2, 3],
[2, 3, 4],
[3, 4, 5],
[4, 5, 6]])>]
现在我们可以使用map()方法将这些窗口的窗口分割为输入和目标:
>>> dataset = dataset.map(lambda S: (S[:, 0], S[:, 1:]))
>>> list(dataset)
[(<tf.Tensor: shape=(4,), dtype=int64, numpy=array([0, 1, 2, 3])>,
<tf.Tensor: shape=(4, 2), dtype=int64, numpy=
array([[1, 2],
[2, 3],
[3, 4],
[4, 5]])>),
(<tf.Tensor: shape=(4,), dtype=int64, numpy=array([1, 2, 3, 4])>,
<tf.Tensor: shape=(4, 2), dtype=int64, numpy=
array([[2, 3],
[3, 4],
[4, 5],
[5, 6]])>)]
现在数据集包含长度为 4 的输入序列,目标是包含下两个步骤的序列,每个时间步。例如,第一个输入序列是[0, 1, 2, 3],对应的目标是[[1, 2], [2, 3], [3, 4], [4, 5]],这是每个时间步的下两个值。如果你和我一样,可能需要几分钟来理解这个概念。慢慢来!
注意
也许令人惊讶的是,目标值包含在输入中出现的值。这是不是作弊?幸运的是,完全不是:在每一个时间步,RNN 只知道过去的时间步;它无法向前看。它被称为因果模型。
让我们创建另一个小型实用函数来为我们的序列到序列模型准备数据集。它还会负责洗牌(可选)和分批处理:
def to_seq2seq_dataset(series, seq_length=56, ahead=14, target_col=1,
batch_size=32, shuffle=False, seed=None):
ds = to_windows(tf.data.Dataset.from_tensor_slices(series), ahead + 1)
ds = to_windows(ds, seq_length).map(lambda S: (S[:, 0], S[:, 1:, 1]))
if shuffle:
ds = ds.shuffle(8 * batch_size, seed=seed)
return ds.batch(batch_size)
现在我们可以使用这个函数来创建数据集:
seq2seq_train = to_seq2seq_dataset(mulvar_train, shuffle=True, seed=42)
seq2seq_valid = to_seq2seq_dataset(mulvar_valid)
最后,我们可以构建序列到序列模型:
seq2seq_model = tf.keras.Sequential([
tf.keras.layers.SimpleRNN(32, return_sequences=True, input_shape=[None, 5]),
tf.keras.layers.Dense(14)
])
这几乎与我们之前的模型完全相同:唯一的区别是在SimpleRNN层中设置了return_sequences=True。这样,它将输出一个向量序列(每个大小为 32),而不是在最后一个时间步输出单个向量。Dense层足够聪明,可以处理序列作为输入:它将在每个时间步应用,以 32 维向量作为输入,并输出 14 维向量。实际上,获得完全相同结果的另一种方法是使用具有核大小为 1 的Conv1D层:Conv1D(14, kernel_size=1)。
提示
Keras 提供了一个TimeDistributed层,允许您将任何向量到向量层应用于输入序列中的每个向量,在每个时间步。它通过有效地重新塑造输入来实现这一点,以便将每个时间步视为单独的实例,然后重新塑造层的输出以恢复时间维度。在我们的情况下,我们不需要它,因为Dense层已经支持序列作为输入。
训练代码与往常一样。在训练期间,使用所有模型的输出,但在训练后,只有最后一个时间步的输出才重要,其余可以忽略。例如,我们可以这样预测未来 14 天的铁路乘客量:
X = mulvar_valid.to_numpy()[np.newaxis, :seq_length]
y_pred_14 = seq2seq_model.predict(X)[0, -1] # only the last time step's output
如果评估此模型对t + 1 的预测,您将发现验证 MAE 为 25,519。对于t + 2,它为 26,274,随着模型试图进一步预测未来,性能会逐渐下降。在t + 14 时,MAE 为 34,322。
提示
您可以结合两种方法来预测多步:例如,您可以训练一个模型,预测未来 14 天,然后将其输出附加到输入,然后再次运行模型,以获取接下来 14 天的预测,并可能重复该过程。
简单的 RNN 在预测时间序列或处理其他类型的序列时可能表现得很好,但在长时间序列或序列上表现不佳。让我们讨论一下原因,并看看我们能做些什么。
处理长序列
要在长序列上训练 RNN,我们必须在许多时间步上运行它,使展开的 RNN 成为一个非常深的网络。就像任何深度神经网络一样,它可能会遇到不稳定的梯度问题,如第十一章中讨论的:可能需要很长时间来训练,或者训练可能不稳定。此外,当 RNN 处理长序列时,它将逐渐忘记序列中的第一个输入。让我们从不稳定的梯度问题开始,看看这两个问题。
解决不稳定梯度问题
许多我们在深度网络中用来缓解不稳定梯度问题的技巧也可以用于 RNN:良好的参数初始化,更快的优化器,辍学等。然而,非饱和激活函数(例如 ReLU)在这里可能不会有太大帮助。实际上,它们可能会导致 RNN 在训练过程中更加不稳定。为什么?嗯,假设梯度下降以一种增加第一个时间步输出的方式更新权重。由于相同的权重在每个时间步使用,第二个时间步的输出也可能略有增加,第三个时间步也是如此,直到输出爆炸——而非饱和激活函数无法阻止这种情况。
您可以通过使用较小的学习率来减少这种风险,或者可以使用饱和激活函数,如双曲正切(这解释了为什么它是默认值)。
同样,梯度本身也可能爆炸。如果注意到训练不稳定,可以监控梯度的大小(例如,使用 TensorBoard),并可能使用梯度裁剪。
此外,批量归一化不能像深度前馈网络那样有效地与 RNN 一起使用。实际上,您不能在时间步之间使用它,只能在循环层之间使用。
更准确地说,从技术上讲,可以在内存单元中添加一个 BN 层(您很快就会看到),以便在每个时间步上应用它(既在该时间步的输入上,也在上一个步骤的隐藏状态上)。然而,相同的 BN 层将在每个时间步上使用相同的参数,而不考虑输入和隐藏状态的实际比例和偏移。实际上,这并不会产生良好的结果,如 César Laurent 等人在2015 年的一篇论文中所证明的:作者发现,只有当 BN 应用于层的输入时才略有益处,而不是应用于隐藏状态。换句话说,当应用于循环层之间(即在图 15-10 中垂直地)时,它略好于什么都不做,但不适用于循环层内部(即水平地)。在 Keras 中,您可以通过在每个循环层之前添加一个 BatchNormalization 层来简单地在层之间应用 BN,但这会减慢训练速度,并且可能帮助不大。
另一种规范化方法在 RNN 中通常效果更好:层归一化。这个想法是由 Jimmy Lei Ba 等人在2016 年的一篇论文中提出的:它与批归一化非常相似,但不同的是,层归一化是在特征维度上进行归一化,而不是在批次维度上。一个优点是它可以在每个时间步上独立地为每个实例计算所需的统计数据。这也意味着它在训练和测试期间的行为是相同的(与 BN 相反),它不需要使用指数移动平均来估计训练集中所有实例的特征统计数据,就像 BN 那样。与 BN 类似,层归一化为每个输入学习一个比例和偏移参数。在 RNN 中,它通常在输入和隐藏状态的线性组合之后立即使用。
让我们使用 Keras 在简单内存单元中实现层归一化。为此,我们需要定义一个自定义内存单元,它就像一个常规层一样,只是它的 call() 方法接受两个参数:当前时间步的 inputs 和上一个时间步的隐藏 states。
请注意,states 参数是一个包含一个或多个张量的列表。在简单的 RNN 单元中,它包含一个张量,等于上一个时间步的输出,但其他单元可能有多个状态张量(例如,LSTMCell 有一个长期状态和一个短期状态,您很快就会看到)。一个单元还必须有一个 state_size 属性和一个 output_size 属性。在简单的 RNN 中,两者都简单地等于单元的数量。以下代码实现了一个自定义内存单元,它将表现得像一个 SimpleRNNCell,但它还会在每个时间步应用层归一化:
class LNSimpleRNNCell(tf.keras.layers.Layer):
def __init__(self, units, activation="tanh", **kwargs):
super().__init__(**kwargs)
self.state_size = units
self.output_size = units
self.simple_rnn_cell = tf.keras.layers.SimpleRNNCell(units,
activation=None)
self.layer_norm = tf.keras.layers.LayerNormalization()
self.activation = tf.keras.activations.get(activation)
def call(self, inputs, states):
outputs, new_states = self.simple_rnn_cell(inputs, states)
norm_outputs = self.activation(self.layer_norm(outputs))
return norm_outputs, [norm_outputs]
让我们来看一下这段代码:
-
我们的 LNSimpleRNNCell 类继承自 tf.keras.layers.Layer 类,就像任何自定义层一样。
-
构造函数接受单位数和所需的激活函数,并设置 state_size 和 output_size 属性,然后创建一个没有激活函数的 SimpleRNNCell(因为我们希望在线性操作之后但在激活函数之前执行层归一化)。然后构造函数创建 LayerNormalization 层,最后获取所需的激活函数。
-
call()方法首先应用simpleRNNCell,它计算当前输入和先前隐藏状态的线性组合,并返回结果两次(实际上,在SimpleRNNCell中,输出就等于隐藏状态:换句话说,new_states[0]等于outputs,因此我们可以在call()方法的其余部分安全地忽略new_states)。接下来,call()方法应用层归一化,然后是激活函数。最后,它将输出返回两次:一次作为输出,一次作为新的隐藏状态。要使用此自定义细胞,我们只需要创建一个tf.keras.layers.RNN层,将其传递给一个细胞实例:
custom_ln_model = tf.keras.Sequential([
tf.keras.layers.RNN(LNSimpleRNNCell(32), return_sequences=True,
input_shape=[None, 5]),
tf.keras.layers.Dense(14)
])
同样,您可以创建一个自定义细胞,在每个时间步之间应用 dropout。但有一个更简单的方法:Keras 提供的大多数循环层和细胞都有dropout和recurrent_dropout超参数:前者定义要应用于输入的 dropout 率,后者定义隐藏状态之间的 dropout 率,即时间步之间。因此,在 RNN 中不需要创建自定义细胞来在每个时间步应用 dropout。
通过这些技术,您可以缓解不稳定梯度问题,并更有效地训练 RNN。现在让我们看看如何解决短期记忆问题。
提示
在预测时间序列时,通常有必要在预测中包含一些误差范围。为此,一种方法是使用 MC dropout,介绍在第十一章中:在训练期间使用recurrent_dropout,然后在推断时通过使用model(X, training=True)来保持 dropout 处于活动状态。多次重复此操作以获得多个略有不同的预测,然后计算每个时间步的这些预测的均值和标准差。
解决短期记忆问题
由于数据在经过 RNN 时经历的转换,每个时间步都会丢失一些信息。过一段时间后,RNN 的状态几乎不包含最初输入的任何痕迹。这可能是一个停滞不前的问题。想象一下多莉鱼试图翻译一句长句子;当她读完时,她已经不记得它是如何开始的。为了解决这个问题,引入了各种具有长期记忆的细胞类型。它们已经被证明非常成功,以至于基本细胞不再被广泛使用。让我们首先看看这些长期记忆细胞中最受欢迎的:LSTM 细胞。
LSTM 细胞
长短期记忆(LSTM)细胞是由 Sepp Hochreiter 和 Jürgen Schmidhuber 于 1997 年提出的,并在多年来逐渐得到了几位研究人员的改进,如 Alex Graves,Haşim Sak 和 Wojciech Zaremba。如果将 LSTM 细胞视为黑匣子,它可以被用作基本细胞,只是它的性能会更好;训练会更快收敛,并且它会检测数据中更长期的模式。在 Keras 中,您可以简单地使用LSTM层而不是SimpleRNN层:
model = tf.keras.Sequential([
tf.keras.layers.LSTM(32, return_sequences=True, input_shape=[None, 5]),
tf.keras.layers.Dense(14)
])
或者,您可以使用通用的tf.keras.layers.RNN层,将LSTMCell作为参数传递给它。但是,当在 GPU 上运行时,LSTM层使用了优化的实现(请参阅第十九章),因此通常最好使用它(RNN层在定义自定义细胞时非常有用,就像我们之前做的那样)。
那么 LSTM 细胞是如何工作的呢?其架构显示在图 15-12 中。如果不看盒子里面的内容,LSTM 细胞看起来与常规细胞完全相同,只是其状态分为两个向量:h[(t)]和c[(t)](“c”代表“cell”)。您可以将h[(t)]视为短期状态,将c[(t)]视为长期状态。
![mls3 1512]()
图 15-12. LSTM 单元
现在让我们打开盒子!关键思想是网络可以学习将什么存储在长期状态中,什么丢弃,以及从中读取什么。当长期状态c[(t–1)]从左到右穿过网络时,您可以看到它首先经过一个遗忘门,丢弃一些记忆,然后通过加法操作添加一些新的记忆(通过输入门选择的记忆)。结果c[(t)]直接发送出去,没有进一步的转换。因此,在每个时间步骤,一些记忆被丢弃,一些记忆被添加。此外,在加法操作之后,长期状态被复制并通过 tanh 函数传递,然后结果由输出门过滤。这产生了短期状态h[(t)](这等于此时间步骤的单元输出y[(t))。现在让我们看看新记忆来自哪里以及门是如何工作的。
首先,当前输入向量x[(t)]和先前的短期状态h[(t–1)]被馈送到四个不同的全连接层。它们各自有不同的作用:
-
主要层是输出g[(t)]的层。它通常的作用是分析当前输入x[(t)]和先前(短期)状态h[(t–1)]。在基本单元中,除了这一层外没有其他内容,其输出直接发送到y[(t)]和h[(t)]。但在 LSTM 单元中,这一层的输出不会直接输出;相反,其最重要的部分存储在长期状态中(其余部分被丢弃)。
-
其他三个层是门控制器。由于它们使用逻辑激活函数,输出范围从 0 到 1。正如您所看到的,门控制器的输出被馈送到逐元素乘法操作:如果它们输出 0,则关闭门,如果它们输出 1,则打开门。具体来说:
-
遗忘门(由f[(t)]控制)控制着应该擦除长期状态的哪些部分。
-
输入门(由i[(t)]控制)控制着应该将g[(t)]的哪些部分添加到长期状态中。
-
最后,输出门(由o[(t)]控制)控制着长期状态的哪些部分应该在此时间步骤被读取并输出,既输出到h[(t)],也输出到y[(t)]。
简而言之,LSTM 单元可以学习识别重要的输入(这是输入门的作用),将其存储在长期状态中,保留它直到需要(这是遗忘门的作用),并在需要时提取它。这解释了为什么这些单元在捕捉时间序列、长文本、音频记录等长期模式方面取得了惊人的成功。
方程 15-4 总结了如何计算单元的长期状态、短期状态以及每个时间步骤的输出,针对单个实例(整个小批量的方程非常相似)。
方程 15-4. LSTM 计算