欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

机器学习实践:基于 Scikit-Learn、Keras 和 TensorFlow 第 10 章 利用 Keras 构建人工神经网络

最编程 2024-06-18 17:56:54
...

Aurélien 在写第二版时,对下半部分深度学习各章节的修订非常非常大(前面机器学习的部分更改只有10%,只是新加了一个第9章),所以还是先看变动大的深度学习部分吧。看了第10章,真是比第一版强太多了,讲的特别细,特别有耐心。

下载本书代码和电子书:https://cloud.tencent.com/developer/article/1512788


第10章 使用Keras搭建人工神经网络


鸟类启发人类飞翔,东洋参启发了魔术贴的发明,大自然启发人类实现了无数发明创造。通过研究大脑来制造智能机器,也符合这个逻辑。人工神经网络(ANN)就是沿着这条逻辑诞生的:人工神经网络是受大脑中的生物神经元启发而来的机器学习模型。但是,虽然飞机是受鸟儿启发而来的,飞机却不用挥动翅膀。相似的,人工神经网络和生物神经元网络也是具有不同点的。一些研究者甚至认为,应该彻底摒弃这种生物学类比:例如,用“单元”取代“神经元”,以免人们将创造力局限于生物学系统的合理性上。

人工神经网络是深度学习的核心,它不仅样式多样、功能强大,还具有可伸缩性,这让人工神经网络适宜处理庞大且复杂的机器学习任务,例如对数十亿张图片分类(谷歌图片)、语音识别(苹果Siri)、向数亿用户每天推荐视频(Youtube)、或者通过学习几百围棋世界冠军(DeepMind的AlphaGo)。

本章的第一部分会介绍人工神经网络,从一个简单的ANN架构开始,然后过渡到多层感知机(MLP),后者的应用非常广泛(后面的章节会介绍其他的架构)。第二部分会介绍如何使用流行的Keras API搭建神经网络,Keras API是一个设计优美、简单易用的高级API,可以用来搭建、训练、评估、运行神经网络。Keras的易用性,并不妨碍它具有强大的实现能力,Keras足以帮你搭建多种多样的神经网络。事实上,Keras足以完成大多数的任务啦!要是你需要实现更多的功能,你可以用Keras的低级API(第12章介绍)自己写一些组件。

从生物神经元到人工神经元

颇让人惊讶的地方是,其实ANN已经诞生相当长时间了:神经生理学家Warren McCulloch和数学家Walter Pitts在1943年首次提出了ANN。在他们里程碑的论文《A Logical Calculus of Ideas Immanent in Nervous Activity》中(https://scholar.google.com/scholar?q=A+Logical+Calculus+of+Ideas+Immanent+in+Nervous+Activity+author%3Amcculloch),McCulloch 和 Pitts介绍一个简单的计算模型,关于生物大脑的神经元是如何通过命题逻辑协同工作的。这是第一个ANN架构,后来才出现更多的ANN架构。

ANN的早期成功让人们广泛相信,人类马上就能造出真正的智能机器了。1960年代,当这个想法落空时,资助神经网络的钱锐减,ANN进入了寒冬。1980年代早期,诞生了新的神经网络架构和新的训练方法,连结主义(研究神经网络)复苏,但是进展很慢。到了1990年代,出现了一批强大的机器学习方法,比如支持向量机(见第05章)。这些新方法的结果更优,也比ANN具有更扎实的理论基础,神经网络研究又一次进入寒冬。我们正在经历的是第三次神经网络浪潮。这波浪潮会像前两次那样吗?这次与前两次有所不同,这一次会对我们的生活产生更大的影响,理由如下:

  • 我们现在有更多的数据,用于训练神经网络,在大而复杂的问题上,ANN比其它ML技术表现更好;
  • 自从1990年代,计算能力突飞猛进,现在已经可以在理想的时间内训练出大规模的神经网络了。一部分原因是摩尔定律(在过去50年间,集成电路中的组件数每两年就翻了一倍),另外要归功于游戏产业,后者生产出了强大的GPU显卡。还有,云平台使得任何人都能使用这些计算能力;
  • 训练算法得到了提升。虽然相比1990年代,算法变化不大,但这一点改进却产生了非常大的影响;
  • 在实践中,人工神经网络的一些理论局限没有那么强。例如,许多人认为人工神经网络训练算法效果一般,因为它们很可能陷入局部最优,但事实证明,这在实践中是相当罕见的(或者如果它发生,它们也通常相当接近全局最优);
  • ANN已经进入了资助和进步的良性循环。基于ANN的惊艳产品常常上头条,从而吸引了越来越多的关注和资金,促进越来越多的进步和更惊艳的产品。

生物神经元

在讨论人工神经元之前,先来看看生物神经元(见图10-1)。这是动物大脑中一种不太常见的细胞,包括:细胞体(含有细胞核和大部分细胞组织),许多貌似树枝的树突,和一条非常长的轴突。轴突的长度可能是细胞体的几倍,也可能是一万倍。在轴突的末梢,轴突分叉成为终树突,终树突的末梢是突触,突触连接着其它神经元的树突或细胞体。

生物神经元会产生被称为“动作电位”(或称为信号)的短促电脉冲,信号沿轴突传递,使突触释放出被称为神经递质的化学信号。当神经元在几毫秒内接收了足够量的神经递质,这个神经元也会发送电脉冲(事实上,要取决于神经递质,一些神经递质会禁止发送电脉冲)。

图10-1 生物神经元

独立的生物神经元就是这样工作的,但因为神经元是处于数十亿神经元的网络中的,每个神经元都连着几千个神经元。简单神经元的网络可以完成高度复杂的计算,就好像蚂蚁齐心协力就能建成复杂的蚁冢一样。生物神经网络(BNN)如今仍是活跃的研究领域,人们通过绘制出了部分大脑的结构,发现神经元分布在连续的皮层上,尤其是在大脑皮质上(大脑外层),见图10-2。

图10-2 人类大脑皮质的多层神经元网络

神经元的逻辑计算

McCulloch和Pitts提出了一个非常简单的生物神经元模型,它后来演化成了人工神经元:一个或多个二元(开或关)输入,一个二元输出。当达到一定的输入量时,神经元就会产生输出。在论文中,两位作者证明就算用如此简单的模型,就可以搭建一个可以完成任何逻辑命题计算的神经网络。为了展示网络是如何运行的,我们自己亲手搭建一些不同逻辑计算的ANN(见图10-3),假设有两个活跃的输入时,神经元就被激活。

图10-3 不同逻辑计算的ANN

这些网络的逻辑计算如下:

  • 左边第一个网络是确认函数:如果神经元 A 被激活,那么神经元 C 也被激活(因为它接收来自神经元 A 的两个输入信号),但是如果神经元 A 关闭,那么神经元 C 也关闭。
  • 第二个网络执行逻辑 AND:神经元 C 只有在激活神经元 A 和 B(单个输入信号不足以激活神经元 C)时才被激活。
  • 第三个网络执行逻辑 OR:如果神经元 A 或神经元 B 被激活(或两者),神经元 C 被激活。
  • 最后,如果我们假设输入连接可以抑制神经元的活动(生物神经元是这样的情况),那么第四个网络计算一个稍微复杂的逻辑命题:如果神经元 B 关闭,只有当神经元A是激活的,神经元 C 才被激活。如果神经元 A 始终是激活的,那么你得到一个逻辑 NOT:神经元 C 在神经元 B 关闭时是激活的,反之亦然。

你可以很容易地想到,如何将这些网络组合起来计算复杂的逻辑表达式(参见本章末尾的练习)。

感知机

感知器是最简单的人工神经网络结构之一,由 Frank Rosenblatt 发明于 1957年。它基于一种稍微不同的人工神经元(见图 10-4),阈值逻辑单元(TLU),或称为线性阈值单元(LTU):输入和输出是数字(而不是二元开/关值),并且每个输入连接都一个权重。TLU计算其输入的加权和(z = W1x1 + W2x2 + ... + Wnxn = xT·W),然后将阶跃函数应用于该和,并输出结果:hW(x) = step(z),其中z = xT·W。

图10-4 阈值逻辑单元:人工神经元做权重求和,然后对和做阶跃函数

感知机最常用的阶跃函数是单位阶跃函数(Heaviside step function),见公式10-1。有时候也使用符号函数sgn。

公式10-1 感知机常用的阶跃函数,阈值为0

单一TLU 可用于简单的线性二元分类。它计算输入的线性组合,如果结果超过阈值,它输出正类或者输出负类(就像逻辑回归分类或线性SVM分类)。例如,你可以使用单一 TLU,基于花瓣长度和宽度分类鸢尾花(也可添加额外的偏置特征x0=1,就像我们在前面章节所做的那样)。训练 TLU 意味着去寻找合适的W0、W1和W2值(训练算法稍后提到)。

感知器只由一层 TLU 组成,每个TLU连接到所有输入。当一层的神经元连接着前一层的每个神经元时,该层被称为全连接层,或紧密层。感知机的输入来自输入神经元,输入神经元只输出从输入层接收的任何输入。所有的输入神经元位于输入层。此外,通常再添加一个偏置特征(X0=1):这种偏置特性通常用一种称为偏置神经元的特殊类型的神经元来表示,它总是输出 1。图10-5展示了一个具有两个输入和三个输出的感知机,它可以将实例同时分成为三个不同的二元类,这使它成为一个多输出分类器。。

图10-5 一个具有两个输入神经元、一个偏置神经元和三个输出神经元的感知机架构

借助线性代数,利用公式10-2可以方便地同时算出几个实例的一层神经网络的输出。

公式10-2 计算一个全连接层的输出

在这个公式中,

  • X表示输入特征矩阵,每行是一个实例,每列是一个特征;
  • 权重矩阵W包含所有的连接权重,除了偏置神经元。每有一个输入神经元权重矩阵就有一行,神经层每有一个神经元权重矩阵就有一列;
  • 偏置矢量b含有所有偏置神经元和人工神经元的连接权重。每有一个人工神经元就对应一个偏置项;
  • 函数

被称为激活函数,当人工神经网络是TLU时,激活函数是阶跃函数(后面会讨论更多的激活函数)。

那么感知器是如何训练的呢?Frank Rosenblatt 提出的感知器训练算法在很大程度上受到 Hebb 规则的启发。在 1949 出版的《行为组织》一书中,Donald Hebb 提出,当一个生物神经元经常触发另一个神经元时,这两个神经元之间的联系就会变得更强。这个想法后来被 Siegrid Löwel 总结为一经典短语:“一起燃烧的细胞,汇合在一起。”这个规则后来被称为 Hebb 规则(或 Hebbian learning)。使用这个规则的变体来训练感知器,该规则考虑了网络所犯的误差。更具体地,感知器一次被馈送一个训练实例,对于每个实例,它进行预测。对于每一个产生错误预测的输出神经元,修正输入的连接权重,以获得正确的预测。公式 10-3 展示了Hebb 规则。

公式10-3 感知机的学习规则(权重更新)

在这个公式中:

  • 其中wi,j是第i个输入神经元与第j个输出神经元之间的连接权重;
  • xi是当前训练实例的第i个输入值;

j是当前训练实例的第j个输出神经元的输出;

  • yj是当前训练实例的第j个输出神经元的目标输出;
  • η是学习率。

每个输出神经元的决策边界是线性的,因此感知器不能学习复杂的模式(比如 Logistic 回归分类器)。然而,如果训练实例是线性可分的,Rosenblatt 证明该算法将收敛到一个解。这被称为感知器收敛定理。

Sscikit-Llearn 提供了一个Perceptron类,它实现了一个 单TLU 网络。它可以实现大部分功能,例如用于 iris 数据集(第4章中介绍过):

代码语言:javascript
复制
import numpy as np
from sklearn.datasets import load_iris
from sklearn.linear_model import Perceptron

iris = load_iris()
X = iris.data[:, (2, 3)]  # petal length, petal width
y = (iris.target == 0).astype(np.int)  # Iris setosa?

per_clf = Perceptron()
per_clf.fit(X, y)

y_pred = per_clf.predict([[2, 0.5]])

你可能注意到,感知器学习算法和随机梯度下降很像。事实上,sklearn 的Perceptron类相当于使用具有以下超参数的 SGDClassifierloss="perceptron"learning_rate="constant"eta0=1(学习率),penalty=None(无正则化)。

与逻辑回归分类器相反,感知机不输出类概率,而是基于硬阈值进行预测。这是逻辑回归优于感知机的一点。

在1969 年题为“感知机”的专著中,Marvin Minsky 和 Seymour Papert 强调了感知器的许多严重缺陷,特别是它们不能解决一些琐碎的问题(例如,异或(XOR)分类问题);参见图 10-6 的左侧)。当然,其他的线性分类模型(如 Logistic 回归分类器)也都实现不了,但研究人员期望从感知器中得到更多,他们的失望是很大的,导致许多人彻底放弃了神经网络,而是转向高层次的问题,如逻辑、问题解决和搜索。

然而,事实证明,感知机的一些局限性可以通过堆叠多个感知机消除。由此产生的人工神经网络被称为多层感知机(MLP)。特别地,MLP 可以解决 XOR 问题,你可以通过计算图 10-6 右侧所示的 MLP 的输出来验证输入的每一个组合:输入(0, 0)或(1, 1)网络输出 0,输入(0, 1)或(1, 0)它输出 1。除了四个连接的权重不是1,其它连接都是1。

图10-6 XOR分类问题和MLP

多层感知机与反向传播

MLP 由一个输入层、一个或多个称为隐藏层的 TLU 组成,一个 TLU 层称为输出层(见图 10-7)。靠近输入层的层,通常被称为浅层,靠近输出层的层通常被称为上层。除了输出层,每一层都有一个偏置神经元,并且全连接到下一层。

图10-7 多层感知器

注意:信号是从输入到输出单向流动的,因此这种架构被称为前馈神经网络(FNN)。

当人工神经网络有多个隐含层时,称为深度神经网络(DNN)。深度学习研究的是DNN和深层计算模型。但是大多数人用深度学习泛化代替神经网络,即便网络很浅时。

多年来,研究人员努力寻找一种训练 MLP 的方法,但没有成功。但在 1986,David Rumelhart、Geoffrey Hinton、Ronald Williams 发表了一篇突破性的论文(https://scholar.google.com/scholar?q=Learning+Internal+Representations+by+Error+Propagation+author%3Arumelhart),提出了至今仍在使用的反向传播训练算法。总而言之,反向传播算法是使用了高效梯度计算的梯度下降算法(见第4章):只需要两次网络传播(一次向前,一次向后),就可以算出网络误差的、和每个独立模型参数相关的梯度。换句话说,反向传播算法为了减小误差,可以算出每个连接权重和每个偏置项的调整量。当得到梯度之后,就做一次常规的梯度下降,不断重复这个过程,直到网络得到收敛解。

笔记:自动计算梯度被称为自动微分。有多种自动微分的方法,各有优缺点。反向传播使用的是反向模式自微分。这种方法快而准,当函数有多个变量(连接权重)和多个输出(损失函数)要微分时也能应对。附录D介绍了自微分。

对BP做详细分解:

  • 每次处理一个微批次(假如每个批次包含32个实例),用训练集多次训练BP,每次被称为一个周期(epoch);
  • 每个微批次先进入输入层,输入层再将其发到第一个隐藏层。计算得到该层所有神经元的(微批次的每个实例的)输出。输出接着传到下一层,直到得到输出层的输出。这个过程就是前向传播:就像做预测一样,只是保存了每个中间结果,中间结果要用于反向传播;
  • 然后计算输出误差(使用损失函数比较目标值和实际输出值,然后返回误差);
  • 接着,计算每个输出连接对误差的贡献量。这是通过链式法则(就是对多个变量做微分的方法)实现的;
  • 然后还是使用链式法则,计算最后一个隐藏层的每个连接对误差的贡献,这个过程不断向后传播,直到到达输入层。
  • 最后,BP算法做一次梯度下降步骤,用刚刚计算的误差梯度调整所有连接权重。

BP算法十分重要,再归纳一下:对每个训练实例,BP算法先做一次预测(前向传播),然后计算误差,然后反向通过每一层以测量误差贡献量(反向传播),最后调整所有连接权重以降低误差(梯度下降)。(译者注:我也总结下吧,每次训练都先是要设置周期epoch数,每次epoch其实做的就是三件事,向前传一次,向后传一次,然后调整参数,接着再进行下一次epoch。)

警告:随机初始化隐藏层的连接权重是很重要的。假如所有的权重和偏置都初始化为0,则在给定一层的所有神经元都是一样的,BP算法对这些神经元的调整也会是一样的。换句话,就算每层有几百个神经元,模型的整体表现就像每层只有一个神经元一样,模型会显得笨笨的。如果权重是随机初始化的,就可以打破对称性,训练出不同的神经元。

为了使BP算法正常工作,作者对 MLP 的架构做了一个关键调整:用Logistic函数(sigmoid)代替阶跃函数,σ(z) = 1 / (1 + exp(–z))。这是必要的,因为阶跃函数只包含平坦的段,因此没有梯度(梯度下降不能在平面上移动),而 Logistic函数处处都有一个定义良好的非零导数,允许梯度下降在每步上取得一些进展。反向传播算法也可以与其他激活函数一起使用,下面就是两个流行的激活函数:

  • 双曲正切函数: tanh (z) = 2σ(2z) – 1

类似 Logistic 函数,它是 S 形、连续可微的,但是它的输出值范围从-1到1(不是 Logistic 函数的 0 到 1),这往往使每层的输出在训练开始时或多或少都变得以 0 为中心,这常常有助于加快收敛速度。

  • ReLU 函数:ReLU(z) = max(0, z)

ReLU 函数是连续的,但是在z=0时不可微(斜率突然改变,导致梯度下降在0点左右跳跃),ReLU的变体是当z<0时,z=0。但在实践中,ReLU效果很好,并且具有计算快速的优点,于是成为了默认激活函数。最重要的是,它没有最大输出值,这有助于减少梯度下降期间的一些问题(第 11 章再介绍)。

这些流行的激活函数及其变体如图 10-8 所示。但是,究竟为什么需要激活函数呢?如果将几个线性变化链式组合起来,得到的还是线性变换。比如,对于 f(x) = 2x + 3g(x) = 5x – 1 ,两者组合起来仍是线性变换:f(g(x)) = 2(5x – 1) + 3 = 10x + 1。如果层之间不具有非线性,则深层网络和单层网络其实是等同的,这样就不能解决复杂问题。相反的,足够深且有非线性激活函数的DNN,在理论上可以近似于任意连续函数。

图10-8 激活函数及其变体

知道了神经网络的起源、架构、计算方法、BP算法,接下来看应用。

回归MLP

首先,MLP可以用来回归任务。如果想要预测一个单值(例如根据许多特征预测房价),就只需要一个输出神经元,它的输出值就是预测值。对于多变量回归(即一次预测多个值),则每一维度都要有一个神经元。例如,想要定位一张图片的中心,就要预测2D坐标,因此需要两个输出神经元。如果再给对象加个边框,还需要两个值:对象的宽度和高度。

通常,当用MLP做回归时,输出神经元不需要任何激活函数。如果要让输出是正值,则可在输出值使用ReLU激活函数。另外,还可以使用softplus激活函数,这是ReLu的一个平滑化变体:softplus(z) = log(1 + exp(z))。z是负值时,softplus接近0,z是正值时,softplus接近z。最后,如果想让输出落入一定范围内,则可以使用调整过的Logistic或双曲正切函数:Logistic函数用于0到1,双曲正切函数用于-1到1。

训练中的损失函数一般是均方误差,但如果训练集有许多异常值,则可以使用平均绝对误差。另外,也可以使用Huber损失函数,它是前两者的组合。

提示:当误差小于阈值δ时(一般为1),Huber损失函数是二次的;误差大于阈值时,Huber损失函数是线性的。相比均方误差,线性部分可以让Huber对异常值不那么敏感,二次部分可以让收敛更快,也比均绝对误差更精确。

表10-1 总结了回归MLP的典型架构。

表10-1 回归MLP的典型架构

分类MLP

MLP也可用于分类,对于二元分类问题,只需要一个使用Logistic激活的输出神经元:输出是一个0和1之间的值,作为正类的估计概率。

MLP也可以处理多标签二元分类(见第3章)。例如,邮件分类系统可以预测一封邮件是垃圾邮件,还是正常邮件,同时预测是紧急,还是非紧急邮件。这时,就需要两个输出神经元,两个都是用Logistic函数:第一个输出垃圾邮件的概率,第二个输出紧急的概率。更为一般的讲,需要为每个正类配一个输出神经元。多个输出概率的和不一定非要等于1。这样模型就可以输出各种标签的组合:非紧急非垃圾邮件、紧急非垃圾邮件、非紧急垃圾邮件、紧急垃圾邮件。

如果每个实例只能属于一个类,但可能是三个或多个类中的一个(比如对于数字图片分类,可以使class 0到class 9),则每一类都要有一个输出神经元,整个输出层(见图10-9)要使用softmax激活函数。softmax函数可以保证,每个估计概率位于0和1之间,并且各个值相加等于1。这被称为多类分类。

图10-9 一个用于分类的MLP(包括ReLU和softmax)

根据损失函数,因为要预测概率分布,交叉商损失函数(也称为log损失,见第4章)是不错的选择。

表10-2概括了分类MLP的典型架构。

表10-2 分类MLP的典型架构

提示:看下面的内容前,建议看看本章末尾的习题1。利用TensorFlow Playground可视化各样的神经网络架构,可以更深入的理解MLP和超参数(层数、神经元数、激活函数)的作用。

用Keras实现MLP

Keras是一个深度学习高级API,可以用它轻松地搭建、训练、评估和运行各种神经网络。Keras的文档见https://keras.io/。Keras参考实现(https://github.com/keras-team/keras)是François Chollet开发的,于2015年3月开源。得益于Keras简单易用灵活优美,迅速流行开来。为了进行神经网络计算,必须要有计算后端的支持。目前可选三个流行库:TensorFlow、CNTK和Theano。为避免误会,将GitHub上的Keras参考实现称为多后端Keras。

自从2016年底,出现了Kera的其它实现。现在已经可以在Apache MXNet、苹果Core ML、JavaScript或TypeScript(浏览器)、PlaidML(各种GPU,不限于Nvidia)上运行Keras。另外,TensorFlow也捆绑了自身的Keras实现 —— tf.keras,它只支持TensorFlow作为后端,但提供了更多使用的功能(见图10-10):例如,tf.keras支持TensorFlow的Data API,加载数据更轻松,预处理数据更高效。因此,本书使用的是tf.keras。本章的代码不局限于TensorFlow,只需要一些修改,比如修改引入,也可以在其他Keras实现上运行。

图10-10 Keras API的两个实现:左边是多后端Keras,右边是tf.keras

排在Keras和TensorFlow之后最流行的深度学习库,是Facebook的PyTorch。PyTorch的API与Keras很像,所以掌握了Keras,切换到PyTorch也不难。得益于易用性和详实的文档(TensorFlow 1的文档比较一般),PyTorch在2018年广泛流行开来。但是,TensorFlow 2 和PyTorch一样简单易用,因为TensorFlow使用了Keras作为它的高级API,并简化清理了TensorFlow的其它API。TensorFlow的文档也改观了,容易检索多了。相似的,PyTorch的缺点(可移植性差,没有计算图分析)在PyTorch 1.0版本中也得到了优化。良性竞争可以使所有人获益。(作者这段讲的真好!)

安装TensorFlow 2

假设已经在第2章中安装了Jupyter和Scikit-Learn,使用pip安装TensorFlow。如果使用了virtualenv,先要激活虚拟环境:

代码语言:javascript
复制
$ cd $ML_PATH                 # Your ML working directory (e.g., $HOME/ml)
$ source my_env/bin/activate  # on Linux or macOS
$ .\my_env\Scripts\activate   # on Windows

然后安装TensorFlow 2(如果没有使用虚拟环境,需要管理员权限,或加上选项--user):

代码语言:javascript
复制
$ python3 -m pip install --upgrade tensorflow

笔记:要使用GPU的话,在动笔写书的此刻,需要安装tensorflow-gpu,而不是tensorflow。但是TensorFlow团队正在开发一个既支持CPU也支持GPU的独立的库。要支持GPU的话,可能还要安装更多的库,参考https://tensorflow.org/install。第19章会深入介绍GPU。

要测试安装是否成功,可以在Python终端或Jupyter notebook中引入TensorFlow和tf.keras,然后打印其版本号:

代码语言:javascript
复制
>>> import tensorflow as tf
>>> from tensorflow import keras
>>> tf.__version__
'2.0.0'
>>> keras.__version__
'2.2.4-tf'

第二个版本号的末尾带有-tf,表明是tf.keras实现的Keras API,还有一些TensorFlow的专有功能。

使用Sequential API创建图片分类器

首先加载数据集。这章用的数据集是Fashion MNIST,它是MNIST一个替代品,格式与MNIST完全相同(70000张灰度图,每张的像素是28 × 28,共有10类),图的内容是流行物品,而不是数字,每类中的图片更丰富,识图的挑战性比MNIST高得多。例如,线性模型可以在MNIST上达到92%的准确率,但在Fashion MNIST上只有83%的准确率。

使用Keras加载数据集

Keras提供一些实用的函数用来获取和加载常见的数据集,包括MNIST、Fashion MNIST和第2章用过的加州房产数据集。加载Fashion MNIST:

代码语言:javascript
复制
fashion_mnist = keras.datasets.fashion_mnist
(X_train_full, y_train_full), (X_test, y_test) = fashion_mnist.load_data()

当使用Keras加载MNIST 或 Fashion MNIST时,和Scikit-Learn加载数据的一个重要区别是,每张图片是28 × 28 的数组,而不是大小是784的1D数组。另外像素的强度是用整数(0到255)表示的,而不是浮点数(0.0到255.0)。看下训练集的形状和类型:

代码语言:javascript
复制
>>> X_train_full.shape
(60000, 28, 28)
>>> X_train_full.dtype
dtype('uint8')

该数据集已经分成了训练集和测试集,但没有验证集。所以要建一个验证集,另外,因为要用梯度下降训练神经网络,必须要对输入特征进行缩放。简单起见,通过除以255.0将强度范围变为0-1:

代码语言:javascript
复制
X_valid, X_train = X_train_full[:5000] / 255.0, X_train_full[5000:] / 255.0
y_valid, y_train = y_train_full[:5000], y_train_full[5000:]

对于MNIST,当标签等于5时,表明图片是手写的数字5。但对于Fashion MNIST,需要分类名的列表:

代码语言:javascript
复制
class_names = ["T-shirt/top", "Trouser", "Pullover", "Dress", "Coat",
               "Sandal", "Shirt", "Sneaker", "Bag", "Ankle boot"]

例如,训练集的第一张图片表示外套:

代码语言:javascript
复制
>>> class_names[y_train[0]]
'Coat'

图10-11 展示了Fashion MNIST数据集的一些样本。

图10-11 Fashion MNIST数据集的一些样本

用 Sequential API 创建模型

搭建一个拥有两个隐含层的分类MLP:

代码语言:javascript
复制
model = keras.models.Sequential()
model.add(keras.layers.Flatten(input_shape=[28, 28]))
model.add(keras.layers.Dense(300, activation="relu"))
model.add(keras.layers.Dense(100, activation="relu"))
model.add(keras.layers.Dense(10, activation="softmax"))

逐行看下代码:

  • 第一行代码创建了一个Sequential模型,这是Keras最简单的模型,是由单层神经元顺序连起来的,被称为Sequential API;
  • 接下来创建了第一层,这是一个Flatten层,它的作用是将每个输入图片转变为1D数组:如果输入数据是X,该层则计算X.reshape(-1, 1)。该层没有任何参数,只是做一些简单预处理。因为是模型的第一层,必须要指明input_shapeinput_shape不包括批次大小,只是实例的形状。另外,第一层也可以是keras.layers.InputLayer,设置input_shape=[28,28];
  • 然后,添加了一个有300个神经元的紧密层,激活函数是ReLU。每个紧密层只负责自身的权重矩阵,权重矩阵是神经元与输入的所有连接权重。紧密层还要负责偏置项(每个神经元都有一个偏置项)矢量。当紧密层收到输入数据时,就利用公式10-2进行计算;
  • 接着再添加第二个紧密层,激活函数仍然是ReLU;
  • 最后,加上一个拥有10个神经元的输出层(每有一个类就要有一个神经元),激活函数是softmax(保证输出的概率和等于1,因为就只有这是个类,具有排他性)。

提示:设置activation="relu",等同于activation=keras.activations.relukeras.activations包中还有其它激活函数,完整列表见https://keras.io/activations/

除了一层一层加层,也可以传递一个层组成的列表:

代码语言:javascript
复制
model = keras.models.Sequential([
    keras.layers.Flatten(input_shape=[28, 28]),
    keras.layers.Dense(300, activation="relu"),
    keras.layers.Dense(100, activation="relu"),
    keras.layers.Dense(10, activation="softmax")
])

使用KERAS.IO的代码实例 keras.io上的代码也可以用于tf.keras,但是需要修改引入。例如,对于下面的代码: from keras.layers import Dense output_layer = Dense(10) 需要改成: from tensorflow.keras.layers import Dense output_layer = Dense(10) 或使用完整路径: from tensorflow import keras output_layer = keras.layers.Dense(10) 这么写就是麻烦点,但是我在本书中是采用的这种方法,因为不仅可以容易看出使用的是哪个包,还可以避免搞混标准类和自定义类。在生产环境中,我倾向于使用前种方式。还有人喜欢这样引入,tensorflow.keras import layers,使用layers.Dense(10)

模型的summary()方法可以展示所有层,包括每个层的名字(名字是自动生成的,除非建层时指定名字),输出的形状(None代表批次大小可以是任意值),和参数的数量。最后会输出所有参数的数量,包括可训练和不可训练参数。这章只有可训练参数(第11章可以看到不可训练参数的例子):

代码语言:javascript
复制
>>> 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
_________________________________________________________________

紧密层通常有许多参数。比如,第一个隐含层有784 × 300个连接权重,再加上300个偏置项,总共有235500个参数。这么多参数可以让模型具有足够的灵活度以拟合训练数据,但也意味着可能有过拟合的风险,特别是当训练数据不足时。后面再讨论这个问题。

使用属性,获取神经层很容易,可以通过索引或名称获取对应的层:

代码语言:javascript
复制
>>> model.layers
[<tensorflow.python.keras.layers.core.Flatten at 0x132414e48>,
 <tensorflow.python.keras.layers.core.Dense at 0x1324149b0>,
 <tensorflow.python.keras.layers.core.Dense at 0x1356ba8d0>,
 <tensorflow.python.keras.layers.core.Dense at 0x13240d240>]
>>> hidden1 = model.layers[1]
>>> hidden1.name
'dense'
>>> model.get_layer('dense') is hidden1
True

可以用get_weights()set_weights()方法,获取神经层的所有参数。对于紧密层,参数包括连接权重和偏置项:

代码语言:javascript
复制
>>> weights, biases = hidden1.get_weights()
>>> weights
array([[ 0.02448617, -0.00877795, -0.02189048, ..., -0.02766046,
         0.03859074, -0.06889391],
       ...,
       [-0.06022581,  0.01577859, -0.02585464, ..., -0.00527829,
         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,)

紧密层是随机初始化连接权重的(为了避免对称性),偏置项则是0。如果想使用不同的初始化方法,可以在创建层时设置kernel_initializer(kernel是连接矩阵的另一个名字)或bias_initializer。第11章会进一步讨论初始化器,初始化器的完整列表见https://keras.io/initializers/

笔记:权重矩阵的形状取决于输入的数量。这就是为什么要在创建Sequential模型的第一层时指定input_shape。但是,如果不指定形状也没关系:Keras会在真正搭建模型前一直等待,直到弄清输入的形状(输入真实数据时,或调用build()方法时)。在搭建模型之前,神经层是没有权重的,也干不了什么事(比如打印模型概要或保存模型)。所以如果在创建模型时知道输入的形状,最好就设置好。

编译模型

创建好模型之后,必须调用compile()方法,设置损失函数和优化器。另外,还可以指定训练和评估过程中要计算的额外指标的列表:

代码语言:javascript
复制
model.compile(loss="sparse_categorical_crossentropy",
              optimizer="sgd",
              metrics=["accuracy"])

笔记:使用loss="sparse_categorical_crossentropy"等同于loss=keras.losses.sparse_categorical_crossentropy。相思的,optimizer="sgd"等同于optimizer=keras.optimizers.SGD()metrics=["accuracy"]等同于metrics=[keras.metrics.sparse_categorical_accuracy]。后面还会使用其他的损失函数、优化器和指标,它们的完整列表见https://keras.io/losseshttps://keras.io/optimizers、和 https://keras.io/metrics

解释下这段代码。首先,因为使用的是稀疏标签(每个实例只有一个目标类的索引,在这个例子中,目标类索引是0到9),且就是这十个类,没有其它的,所以使用的是"sparse_categorical_crossentropy"损失函数。如果每个实例的每个类都有一个目标概率(比如独热矢量,[0., 0., 0., 1., 0., 0., 0., 0., 0., 0.],来表示类3),则就要使用"categorical_crossentropy"损失函数。如果是做二元分类(有一个或多个二元标签),输出层就得使用"sigmoid"激活函数,损失函数则变为"binary_crossentropy"

提示:如果要将稀疏标签转变为独热矢量标签,可以使用函数keras.utils.to_categorical()。还以使用函数np.argmax()axis=1

对于优化器,"sgd"表示使用随机梯度下降训练模型。换句话说,Keras会进行反向传播算法。第11章会讨论更高效的优化器(可以提升梯度下降部分,改善不了自动微分部分)。

笔记:使用SGD时,调整学习率很重要,必须要手动设置好,optimizer=keras.optimizers.SGD(lr=???)optimizer="sgd"不同,它的学习率默认为lr=0.01

最后,因为是个分类器,最好在训练和评估时测量"accuracy"

训练和评估模型

可以训练模型了。只需调用fit()方法:

代码语言:javascript
复制
>>> history = model.fit(X_train, y_train, epochs=30,
...                     validation_data=(X_valid, y_valid))
...
Train on 55000 samples, validate on 5000 samples
Epoch 1/30
55000/55000 [======] - 3s 49us/sample - loss: 0.7218     - accuracy: 0.7660
                                      - val_loss: 0.4973 - val_accuracy: 0.8366
Epoch 2/30
55000/55000 [======] - 2s 45us/sample - loss: 0.4840     - accuracy: 0.8327
                                      - val_loss: 0.4456 - val_accuracy: 0.8480
[...]
Epoch 30/30
55000/55000 [======] - 3s 53us/sample - loss: 0.2252     - accuracy: 0.9192
                                      - val_loss: 0.2999 - val_accuracy: 0.8926

这里,向fit()方法传递了输入特征(X_train)和目标类(y_train),还要要训练的周期数(不设置的话,默认的周期数是1,肯定是不能收敛到一个好的解的)。另外还传递了验证集(它是可选的)。Keras会在每个周期结束后,测量损失和指标,这样就可以监测模型的表现。如果模型在训练集上的表现优于在验证集上的表现,可能模型在训练集上就过拟合了(或者就是存在bug,比如训练集和验证集的数据不匹配)。

仅需如此,神经网络就训练好了。训练中的每个周期,Keras会展示到目前为止一共处理了多少个实例(还带有进度条),每个样本的平均训练时间,以及在训练集和验证集上的损失和准确率(和其它指标)。可以看到,损失是一直下降的,这是一个好现象。经过30个周期,验证集的准确率达到了89.26%,与在训练集上的准确率差不多,所以没有过拟合。

提示:除了通过参数validation_data传递验证集,也可以通过参数validation_split从训练集分割出一部分作为验证集。比如,validation_split=0.1可以让Keras使用训练数据(打散前)的末尾10%作为验证集。

如果训练集非常倾斜,一些类过渡表达,一些欠表达,在调用fit()时最好设置class_weight参数,可以加大欠表达类的权重,减小过渡表达类的权重。Keras在计算损失时,会使用这些权重。如果每个实例都要加权重,可以设置sample_weight(这个参数优先于class_weight)。如果一些实例的标签是通过专家添加的,其它实例是通过众包平台添加的,最好加大前者的权重,此时给每个实例都加权重就很有必要。通过在validation_data元组中,给验证集加上样本权重作为第三项,还可以给验证集添加样本权重。

fit()方法会返回History对象,包含:训练参数(history.params)、周期列表(history.epoch)、以及最重要的包含训练集和验证集的每个周期后的损失和指标的字典(history.history)。如果用这个字典创建一个pandas的DataFrame,然后使用方法plot(),就可以画出学习曲线,见图10-12:

代码语言:javascript
复制
import pandas as pd
import matplotlib.pyplot as plt

pd.DataFrame(history.history).plot(figsize=(8, 5))
plt.grid(True)
plt.gca().set_ylim(0, 1) # set the vertical range to [0-1]
plt.show()

图10-12 学习曲线:每个周期的平均训练损失和准确率,验证损失和准确率

可以看到,训练准确率和验证准确率稳步提高,训练损失和验证损失持续下降。另外,验证曲线和训练曲线靠的很近,意味着没有什么过拟合。在这个例子中,在训练一开始时,模型在验证集上的表现由于训练集。但实际情况是,验证误差是在每个周期结束后算出来的,而训练误差在每个周期期间,用流动平均误差算出来的。所以训练曲线(译者注,图中橙色的那条)实际应该向左移动半个周期。移动之后,就可以发现在训练开始时,训练和验证曲线几乎是完美重合起来的。

提示:在绘制训练曲线时,应该向左移动半个周期。

通常只要训练时间足够长,训练集的表现就能超越验证集。从图中可以看到,验证损失仍然在下降,模型收敛的还不好,所以训练应该持续下去。只需要再次调用方法fit()即可,因为Keras可以从断点处继续(验证准确率可以达到89%。)

如果仍然对模型的表现不满意,就需要调节超参数了。首先是学习率。如果调节学习率没有帮助,就尝试换一个优化器(记得再调节任何超参数之后都重新调节学习率)。如果效果仍然不好,就调节模型自身的超参数,比如层数、每层的神经元数,每个隐藏层的激活函数。还可以调节其它超参数,比如批次大小(通过fit()的参数batch_size,默认是32)。本章末尾还会调节超参数。当对验证准确率达到满意之后,就可以用测试集评估泛化误差。只需使用evaluate()方法(evaluate()方法包含参数batch_sizesample_weight):

代码语言:javascript
复制
>>> model.evaluate(X_test, y_test)
10000/10000 [==========] - 0s 29us/sample - loss: 0.3340 - accuracy: 0.8851
[0.3339798209667206, 0.8851]

正如第2章所见,测试集的表现通常比验证集上低一点,这是因为超参数根据验证集而不是测试集调节的(但是在这个例子中,我们没有调节过超参数,所以准确率下降纯粹是运气比较差而已)。一定不要在测试集上调节超参数,否则会影响泛化误差。

使用模型进行预测

接下来,就可以用模型的predict()方法对新实例做预测了。因为并没有新实例,所以就用测试集的前3个实例来演示:

代码语言:javascript
复制
>>> X_new = X_test[:3]
>>> y_proba = model.predict(X_new)
>>> y_proba.round(2)
array([[0.  , 0.  , 0.  , 0.  , 0.  , 0.03, 0.  , 0.01, 0.  , 0.96],
       [0.  , 0.  , 0.98, 0.  , 0.02, 0.  , 0.  , 0.  , 0.  , 0.  ],
       [0.  , 1.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  ]],
      dtype=float32)

可以看到,模型会对每个实例的每个类(从0到9)都给出一个概率。比如,对于第一张图,模型预测第9类(短靴)的概率是96%,第5类(凉鞋)的概率是3%,第7类(运动鞋)的概率是1%,剩下的类的概率都是0。换句话说,模型预测第一张图是鞋,最有可能是短靴,也有可能是凉鞋和运动鞋。如果只关心概率最高的类(即使概率不高),可以使用方法predict_classes()

代码语言:javascript
复制
>>> y_pred = model.predict_classes(X_new)
>>> y_pred
array([9, 2, 1])
>>> np.array(class_names)[y_pred]
array(['Ankle boot', 'Pullover', 'Trouser'], dtype='<U11')

对于这3个实例,模型的判断都是对的(见图10-13):