在本节中,我们将构建一个可以识别手写数字的神经网络。为此,我们将使用MNIST(http://yann.lecun.com/exdb/mnist/),它是一个手写数字数据库,由60 000个训练示例集和10 000个测试示例集组成。训练示例通过人工加了正确的标签。例如,如果手写数字是数字“3”,那么3就是与该示例关联的标签。
在机器学习中,当数据集包含可用的正确标签时,我们即可执行某种形式的有监督学习。此时,可以用训练实例来改善网络。测试实例也包含每个数字对应的正确标签。但是,为了学习,我们的想法是假装标签是未知的,让网络进行预测,然后再重新考察并比较标签,以评估神经网络识别数字的能力。测试实例仅用于测试网络的性能。
每个MNIST图片均为灰度图,由28×28像素组成。这些数字图片的某个子集如图1-12所示。
图1-12 MNIST图片集
我们将使用独热编码(One-Hot Encoding,OHE)来对神经网络的内部信息进行编码。在许多应用中,将分类(非数值)特征转换为数值变量后处理起来会更方便。例如,分类特征[0–9]的任一样本“数字” d ,可被编码为一个含10位的二进制向量,第 d 个位置的值为1,其余位置的值为0。
比如,数字3可以编码为[0, 0, 0, 1, 0, 0, 0, 0, 0, 0]。这种表示形式称为独热编码,有时也简称为“one-hot”,这在数据挖掘领域专门处理数值函数的学习算法中非常常见。
在本节中,我们将使用TensorFlow 2.0定义一个可识别MNIST手写数字的神经网络。先从一个最简单的神经网络开始,然后逐步对其改进。
依据Keras的风格,TensorFlow 2.0提供了合适的库(https://www.tensorflow.org/api_docs/python/tf/keras/datasets)来加载数据集并将其拆分为用于调谐网络的训练集X_train和用于评估性能的测试集X_test。当训练神经网络并归一化到[0, 1]时,数据转换为float32类型,以使用32位精度。另外,将正确标签值分别加载到Y_train和Y_test中,并对它们执行独热编码。下面介绍具体代码。
当下,不要过多地关注为什么某些参数有特定的赋值,这些将在本书的其余部分逐步讨论。直观地讲,EPOCH定义训练的持续时间,BATCH_SIZE表示一次输入网络的样本数量,VALIDATION是为检查或证明训练过程的有效性而保留的数据量。当本章后面探寻不同的值和讨论超参数优化时,你将理解选择参数设置EPOCHS=200、BATCH_SIZE=128、VALIDATION_SPLIT=0.2和N_HIDDEN=128的原因。TensorFlow中神经网络的第一个代码片段如下所示:
从上面的代码中看到,输入层的神经元与图像中每个像素关联,总共有28×28=784个神经元,与MNIST图片中的像素一一对应。
通常,每个像素对应的输入值需要归一化到[0, 1]范围内(这意味着将每个像素的强度除以最大强度值255)。输出值是10个类别之一,每个类别对应一个数字。
最后一层是使用激活函数softmax的单个神经元,它是sigmoid函数的通用形式。如前所述,当输入在(-∞, ∞)范围内变化时,sigmoid函数的输出在(0, 1)范围内。类似地,softmax函数将任意实值的实值K维向量“挤压”为(0, 1)范围内的实值K维向量,并使所有数值求和为1。在我们的代码例子中,它将前一层10个神经元提供的10个结果进行聚合。上述描述内容的具体实现代码如下所示:
定义完模型后,我们需要编译模型,从而让TensorFlow 2.0执行。编译时需要确定一些选择项。首先,要选择一个优化器,它是在训练模型时更新权重的特定算法。其次,要选择一个目标函数,优化器要用它来导航权重空间(通常,目标函数称为损失函数或成本函数,而优化过程定义为损失最小化过程)。第三,要评估训练后的模型。
完整的优化器列表可参考https://www.tensorflow.org/api_docs/python/tf/keras/optimizers。
目标函数的一些常见选择项是:
一种理解多类对数损失函数的思路是将真实值类想象成独热编码向量的表示形式,模型的输出越接近该向量,损失就越小。注意,这种目标函数适用于多类标签预测。它同时也是与softmax激活函数的默认关联选项。
完整的函数列表可见https://www.tensorflow.org/api_docs/python/tf/keras/losses。
度量指标 的一些常见选择是:
完整的度量指标列表可见https://www.tensorflow.org/api_docs/python/tf/keras/metrics。
度量指标类似于目标函数,唯一的不同是它仅用于评估模型,而不用于训练模型。不过,理解度量指标和目标函数之间的差异很重要。如前所述,损失函数用于优化网络,所选的优化器对其最小化。而度量指标则用来判定网络的性能,仅用于网络评估过程,这应与优化过程分离。在某些特定条件下,理想情况是直接对特定度量指标进行优化。然而,某些度量指标对于输入而言是不可微的,这使它们无法被直接使用。
在TensorFlow 2.0中编译模型时,可以为给定模型选择一并使用的优化器、损失函数和度量指标:
随机梯度下降(Stochastic Gradient Descent,SGD) (参见第15章)是一种特殊的优化算法,用于减少神经网络在每次训练周期后的误差。在下一章中将回顾SGD和其他优化算法。一旦模型被编译,即可用fit()方法对其训练,该方法指定了一些参数:
在TensorFlow 2.0中训练一个模型很简单:
注意,这里保留了一部分训练集用于验证。主要想法是利用保留的部分训练数据,从而在训练时评测验证的性能。这对任何机器学习任务来说都是一个很好的实践,将在后续所有示例中采用。在本章稍后讨论过拟合时会再谈论验证。
模型训练完成后,可以用测试集对其进行评估,测试集中包括它在训练阶段未用到的新实例。
注意,训练集和测试集必须是严格分离的。在训练中已经用过的实例上评估模型是毫无意义的。在TensorFlow 2.0中,我们可以使用方法validate(X_test,Y_test)计算test_loss和test_acc:
至此,恭喜!你刚刚在TensorFlow 2.0中定义了你的第一个神经网络。几行代码,你的计算机就应该能识别手写数字。让我们运行代码,看看性能如何。
代码运行结果如图1-13所示。
图1-13 神经网络测试示例的代码运行结果
首先输出的是神经网络的架构,可以看出层的类型(Layer(type))、输出形状(Output Shape)、待优化的参数数量(即权重(Param#))以及它们的连接方式。然后,使用48 000个样本训练网络,并保留12 000个样本进行验证。一旦神经模型创建完成,就用10 000个样本对其进行测试。目前,我们不会深入讨论训练的实现方式,但是可以看出程序运行了200次迭代,每次准确度都有所提高。训练结束后,在测试集上测试模型,训练集达到了约89.96%的准确度,验证集达到了90.70%,测试集达到了90.71%。如图1-14所示。
图1-14 测试模型和准确度的结果图
这意味着将近十分之一的图片被错误分类。我们当然可以做得更好。
好的,目前的准确度基线是训练集89.96%、验证集90.70%、测试集90.71%。这是一个很好的起点,但还可以改善。让我们看看如何做到。
初步改进是向神经网络中添加额外的层,因为这些额外的神经元可能直接有助于它在训练数据中学习更复杂的模式。换句话说,额外层会加入更多参数,从而可能使模型存储更复杂的模式。这样的话,在输入层之后,加入第一个带有N_HIDDEN个神经元和激活函数ReLU的稠密层。该层是隐藏的,因为它不直接与输入和输出相关联。在第一个隐藏层之后,再加入第二个含有N_HIDDEN个神经元的隐藏层,紧随其后的即是含有10个神经元的输出层,当识别出某个数字时,输出层的相关神经元会被触发。以下代码定义了这个新网络:
注意,to_categorical(Y_train,NB_CLASSES)将数组Y_train转换为矩阵,矩阵的列数等于分类结果的个数,而行数保持不变。因此,如果有
那么
运行代码,结果如图1-15所示。
图1-15 多层神经网络的代码运行结果
图1-15显示了运行的初始步骤,图1-16显示了最终结果。图1-16显示,通过添加两个隐藏层,准确度在训练集上达到了90.81%,在验证集上达到了91.40%,在测试集上达到了91.18%。测试准确度较之前提高了,并且迭代次数从200次降到了50次。这很好,但是我们还希望提升更多。
图1-16 增加两个隐藏层后的准确度结果
如果有想法,你可以自己试试,看看如果不添加两个隐藏层,而是仅添加一个隐藏层或者多个隐藏层,会发生什么情况。这个实验留作练习。
注意,在经过一定次数的迭代后,性能改进将不再奏效(或变得几乎不可察觉)。在机器学习中,这种现象称为收敛(convergence)。
现在我们的准确度基线是训练集90.81%、验证集91.40%和测试集91.18%。第二种改进措施非常简单,即在训练过程中随机丢弃(以DROPOUT概率)一些在内部稠密的隐藏层网络中传播的值。在机器学习中,这是一种众所周知的正则化形式。令人惊讶的是,这种随机丢弃一些值的办法能改进网络性能。这种改进背后的思想是,随机失活有助于迫使网络去学习一些更具泛化能力的冗余模式:
像之前一样运行代码迭代200次,可看到该网络的准确度达到了训练集91.70%、验证集94.42%以及测试集94.15%。如图1-17所示。
图1-17 神经网络改进测试的准确度结果
注意,内部隐藏层中含有随机失活的网络可以更好地“泛化”测试集中的未见实例。直观地讲,出现这种现象,一是由于每个神经元深谙它的邻近节点不可靠而变得越来越有能力;二是由于每个神经元强制存储了冗余信息。在测试过程中不存在随机失活,所以神经网络使用了所有高度调谐的神经元。简而言之,测试神经网络性能的好办法是对其使用随机失活。
除此之外,要注意训练集的准确度须高于测试集的准确度,否则,可能是训练时间不够长。上述示例即是此类情况,需要通过增加epoch数来解决。但是,在开始尝试解决之前,需要引入一些其他概念,以使训练过程更快地收敛。先讨论优化器。
先聚焦一个流行的训练技术—— 梯度下降(Gradient Descent, GD) 。试想单个变量 w 的通用成本函数 C(w) ,如图1-18所示。
图1-18 梯度下降优化示例
梯度下降可看作一个沿着陡峭的峡谷山坡走到谷底的远足者。斜率表示通用成本函数 C ,谷底则表示其最小值 C min 。远足者的出发点在 w 0 ,他一点一点地移动,梦想抵达斜率为0的目的地,由于他无法预见目的地的准确位置,所以只能以锯齿状的路线前进。在每个步骤 r ,梯度是增量最大的方向。
数学上,此方向是在步骤 r 时,在点 w r 处的偏导数 的值。因此,通过方向取反 ,远足者即可向谷底接近。
每一步中,远足者可决定到下一个停顿点前走多远路程。在梯度下降术语中,这是所谓的“学习率”( η ≥0)。注意,如果 η 太小,则将移动缓慢。如果 η 过大,则可能会越过谷底。
你应该记得,sigmoid函数是一个连续函数且可导。可以证明,sigmoid函数 的导数为 。
ReLU函数在0处不可导,但可通过将0处的一阶导数定义为0或1,扩展为全域可导。
ReLU函数 y = max(0, x )的分段导数为 。一旦获得它的导数,就可以使用梯度下降技术优化神经网络。TensorFlow负责计算导数值,因此我们无须担心具体实现或实际计算。
神经网络本质上是多个可导函数的组合,这些函数中包含数千甚至数百万个参数。每个网络层计算一个函数,并负责将其误差最小化,以提升在学习阶段的准确度。当讨论反向传播时,会发现误差最小化过程远比这个入门示例更为复杂。但是,它仍然基于下坡到达谷底的初衷。
TensorFlow实现了一种称作SGD的快速梯度下降派生算法,以及许多更高阶的优化技术,比如RMSProp和Adam。这两种算法除了具有SGD的加速度分量外,还具有动量(速度分量)。这可以实现以更多计算量为代价的快速收敛。这个过程可以想象成一个远行者朝某个方向移动时突然决定改变方向,但他仍然记得之前的若干次移动决策。可以证明,动量有助于在相关方向上加速SGD并抑制振荡 [10] 。
完整的优化器列表可参考:https://www.tensorflow.org/api_docs/python/tf/keras/optimizers。
SGD一直是我们刚才构建网络的默认选择。现在,让我们试试其他两个。
只需要更改几行代码:
仅此而已。测试一下,结果如图1-19所示。
图1-19 RMSProp的测试结果
从图中可以看出,RMSProp的收敛速度比SDG快,仅需10个迭代后准确度即可达到训练集97.43%、验证集97.62%和测试集97.64%。这是一个对SDG的重大改进。既然有了一个非常快速的优化器,我们便尝试将迭代数大幅增加到250次,之后准确度达到了训练集98.99%、验证集97.66%、测试集97.77%,如图1-20所示。
图1-20 增加epoch数量
随着迭代次数增加,准确度在训练集和测试集上逐渐提升(见图1-12)。图中,两条曲线大约在15个epoch处交汇,也就是说,无须在该点之后继续训练网络(图像是使用TensorFlow标准工具TensorBoard生成的)。
图1-21 采用RMSProp的准确度和损失曲线示图
尝试下另一个简单的优化器Adam():
如图1-22所示,Adam()的表现稍微好些。经过20次迭代的Adam优化器,准确度达到训练集98.94%、验证集97.89%、测试集97.82%。
图1-22 优化器Adam的测试结果
随着epoch次数增加,准确度在训练集和测试集的提升情况如图1-23所示。注意到,通过选择Adam优化器,可在大约12个epoch或步骤后结束训练。
图1-23 优化器Adam的准确度和损失曲线示图
这是介绍的第5种派生算法,初始基准为测试集90.71%的准确度。截至目前,我们已经做到了逐步改进。然而,增益也越来越难以获得。上述优化采用了30%的随机失活。出于完整性,在测试数据集上记录不同随机失活的准确度会很有用处(如图1-24所示)。在上述示例中,我们选择Adam()作为优化器。还应注意,优化器的选择不能凭空臆定,基于具体问题选择不同的优化器组合会获得不同的性能。
图1-24 不同随机失活的准确度变化示例
再次尝试:将训练过程的epoch数从20增加到200。很不幸,这种改变并没有带来任何增益,但却将计算时间增加了10倍。虽然实验失败,但我们也获知,花更多的时间学习并不一定会改善结果。深度学习更侧重于采纳智能技术,而不是增加计算时间。上述5个派生算法的结果如图1-25所示。
图1-25 不同模型和优化器的准确度
还有一种方法:改变优化器的学习参数。如图1-26所示,三个实验[lr=0.1,lr=0.01,lr=0.001]中最佳值是0.1,这也是优化器的默认学习率。不错!无须配置即可用的Adam优化器。
图1-26 不同学习率的准确度
另一种方法是改变内部隐藏神经元的数量。增加隐藏神经元个数的实验结果显示(如图1-27、图1-28和图1-29所示),通过增加模型的复杂度,运行时间显著增加,这是因为需要优化的参数越来越多。然而,随着网络的增长,通过扩大网络规模而获得的增益却越来越少。注意,在增加隐藏神经元的数量至超过某个特定值后,准确度会降低,这是因为神经网络可能无法很好地泛化,如图1-29所示。
图1-27 增加内部隐藏神经元的参数数量变化
图1-28 增加内部隐藏神经元的计算时间变化
图1-29 增加内部隐藏神经元的测试准确度变化
梯度下降试着最小化训练集中所有实例的损失函数,与此同时,最小化输入样本特性的损失函数。SGD是一种简便的派生算法,它仅考虑样本实例的BATCH_SIZE。看看更改此参数时它的行为变化。正如你看到的,在四个实验中,BATCH_SIZE=64时准确度最佳,如图1-30所示。
图1-30 不同批处理大小的测试准确度
至此,总结一下:通过5个不同的可变因素,我们将性能从90.71%提高到97.82%(见表1-1)。首先,我们在TensorFlow 2.0中定义了一个单层神经网络。然后,添加隐藏层来提高性能。之后,在网络中添加随机失活,进而试验不同类型的优化器来提高测试集上的性能。
表 1-1
但是,随后的两个实验(未在表1-1中显示)并没有带来显著改进。增加内部神经元的数量会创建出更复杂的模型,用高昂的计算量代价换得些许边界效益。同样的结论也适用于增加训练时间。最后一个实验是改变优化器的BATCH_SIZE,它也只带来了边界效益。