Sklearn、TensorFlow 与 Keras 机器学习实用指南第三版(四)
原文:Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow
译者:飞龙
第二部分:神经网络和深度学习
第十章:使用 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,或简称信号)的短电脉冲,这些电脉冲沿着轴突传播,并使突触释放称为神经递质的化学信号。当一个神经元在几毫秒内接收到足够量的这些神经递质时,它会发出自己的电脉冲(实际上,这取决于神经递质,因为其中一些会抑制神经元的发放)。
图 10-1. 一个生物神经元⁴
因此,单个生物神经元似乎表现出简单的方式,但它们组织在一个庞大的网络中,有数十亿个神经元,每个神经元通常连接到成千上万个其他神经元。高度复杂的计算可以通过一个相当简单的神经元网络执行,就像一个复杂的蚁丘可以从简单的蚂蚁的共同努力中出现一样。生物神经网络(BNNs)的架构是活跃研究的主题,但大脑的某些部分已经被绘制出来。这些努力表明,神经元通常组织成连续的层,特别是在大脑的外层皮层(大脑的外层),如图 10-2 所示。
图 10-2. 生物神经网络中的多个层(人类皮层)⁶
使用神经元进行逻辑计算
McCulloch 和 Pitts 提出了生物神经元的一个非常简单的模型,后来被称为人工神经元:它具有一个或多个二进制(开/关)输入和一个二进制输出。当其输入中的活动超过一定数量时,人工神经元会激活其输出。在他们的论文中,McCulloch 和 Pitts 表明,即使使用这样简化的模型,也可以构建一个可以计算任何您想要的逻辑命题的人工神经元网络。为了了解这样一个网络是如何工作的,让我们构建一些执行各种逻辑计算的人工神经网络(请参见图 10-3),假设当至少两个输入连接处于活动状态时,神经元被激活。
图 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。
图 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 中表示。
图 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。感知器学习规则(权重更新)
在这个方程中:
-
w[i,] [j]是第i个输入和第j个神经元之间的连接权重。
-
x[i]是当前训练实例的第i个输入值。
-
是当前训练实例的第j个输出神经元的输出。
-
y[j]是当前训练实例的第j个输出神经元的目标输出。
-
η是学习率(参见第四章)。
每个输出神经元的决策边界是线性的,因此感知器无法学习复杂的模式(就像逻辑回归分类器一样)。然而,如果训练实例是线性可分的,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 问题!
图 10-6. XOR 分类问题及解决该问题的 MLP
注意
与逻辑回归分类器相反,感知器不会输出类概率。这是偏爱逻辑回归而不是感知器的一个原因。此外,感知器默认不使用任何正则化,训练会在训练集上没有更多预测错误时停止,因此该模型通常不会像逻辑回归或线性 SVM 分类器那样泛化得很好。然而,感知器可能训练速度稍快。
多层感知器和反向传播
一个 MLP 由一个输入层、一个或多个称为隐藏层的 TLU 层以及一个称为输出层的 TLU 层组成(请参见图 10-7)。靠近输入层的层通常称为较低层,靠近输出的层通常称为较高层。
图 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 在理论上可以逼近任何连续函数。
图 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-熵或简称对数损失,参见第四章)通常是一个不错的选择。
图 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 数据集的一些样本。
图 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()
图 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)
图 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 强制所有数据通过完整的层堆栈流动;因此,数据中的简单模式可能会被这一系列转换所扭曲。
图 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
。 -
最后,我们创建一个 Keras
Model
,指定要使用的输入和输出。
构建了这个 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])
图 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),以确保网络的基础部分自己学到一些有用的东西,而不依赖于网络的其余部分。
图 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
标量。注意,训练损失在两次运行期间都很好地下降了,但在第二次运行中,由于更高的学习率,下降速度稍快。
图 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,但在较小的数据集上存在过拟合训练集的风险。
图 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 网络慢一点。
图 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 函数:使用这个近似也非常有效,并且计算速度更快。
图 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)。
注意
如果您新任务的输入图片与原始任务中使用的图片大小不同,通常需要添加一个预处理步骤,将它们调整为原始模型期望的大小。更一般地说,当输入具有相似的低级特征时,迁移学习效果最好。
图 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。
图 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的点处测量的梯度)。
图 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)。另一个好处是它需要更少的调整学习率超参数η。
图 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)。
图 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 训练的神经元无法与其相邻的神经元共同适应;它们必须尽可能独立地发挥作用。它们也不能过度依赖少数输入神经元;它们必须关注每个输入神经元。它们最终对输入的轻微变化不太敏感。最终,您将获得一个更健壮的网络,具有更好的泛化能力。
图 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 的更多好理由:
-
首先,该论文建立了 dropout 网络(即包含
Dropout
层的神经网络)与近似贝叶斯推断之间的深刻联系,为 dropout 提供了坚实的数学理论基础。 -
其次,作者引入了一种强大的技术称为MC dropout,它可以提升任何经过训练的 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
调用它。