第二部分:神经网络和深度学习
第十章:使用 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)
heaviside ( z ) = 0 if z < 0 1 if z ≥ 0 sgn ( z ) = - 1 if z < 0 0 if z = 0 + 1 if z > 0
一个单个的 TLU 可以用于简单的线性二元分类。它计算其输入的线性函数,如果结果超过阈值,则输出正类。否则,输出负类。这可能让你想起了逻辑回归(第四章)或线性 SVM 分类(第五章)。例如,你可以使用一个单个的 TLU 基于花瓣长度和宽度对鸢尾花进行分类。训练这样一个 TLU 需要找到正确的w[1]、w[2]和b的值(训练算法将很快讨论)。
一个感知器由一个或多个 TLU 组成,组织在一个单层中,其中每个 TLU 连接到每个输入。这样的一层被称为全连接层或密集层。输入构成输入层。由于 TLU 层产生最终输出,因此被称为输出层。例如,一个具有两个输入和三个输出的感知器在图 10-5 中表示。
图 10-5。具有两个输入和三个输出神经元的感知器的架构
这个感知器可以同时将实例分类为三个不同的二进制类别,这使它成为一个多标签分类器。它也可以用于多类分类。
由于线性代数的魔力,方程 10-2 可以用来高效地计算一层人工神经元对多个实例的输出。
方程 10-2。计算全连接层的输出
h W,b ( X ) = ϕ ( X W + b )
在这个方程中:
- 如常,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 (nextstep) = w i,j + η ( y j - y ^ j ) x i
在这个方程中:
- w[i,] [j]是第i个输入和第j个神经元之间的连接权重。
- x[i]是当前训练实例的第i个输入值。
- y^j是当前训练实例的第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 模型的最后一种方法。
Sklearn、TensorFlow 与 Keras 机器学习实用指南第三版(四)(2)https://developer.aliyun.com/article/1482422