进神经网络的学习方式(译文)----中

过匹配和规范化


诺贝尔奖得主美籍意大利裔物理学家恩里科·费米曾被问到他对一个同僚提出的尝试解决一个重要的未解决物理难题的数学模型。模型和实验非常匹配,但是费米却对其产生了怀疑。他问模型中需要设置的自由参数有多少个。答案是“4”。费米回答道:“我记得我的朋友约翰·冯·诺依曼过去常说,有四个参数,我可以模拟一头大象,而有五个参数,我还能让他卷鼻子。”

这里,其实是说拥有大量的自由参数的模型能够描述特别神奇的现象。即使这样的模型能够很好的拟合已有的数据,但并不表示是一个好模型。因为这可能只是因为模型中足够的自由度使得它可以描述几乎所有给定大小的数据集,不需要对现象的本质有创新的认知。所以发生这种情形时,模型对已有的数据会表现的很好,但是对新的数据很难泛化。对一个模型真正的测验就是它对没有见过的场景的预测能力。

费米和冯·诺依曼对有四个参数的模型就开始怀疑了。我们用来对 MNIST 数字分类的 $$30$$ 个隐藏神经元神经网络拥有将近 $$24,000$$ 个参数!当然很多。我们有 $$100$$ 个隐藏元的网络拥有将近 $$80,000$$ 个参数,而目前最先进的深度神经网络包含百万级或者十亿级的参数。我们应当信赖这些结果么?

让我们将问题暴露出来,通过构造一个网络泛化能力很差的例子。我们的网络有 $$30$$ 个隐藏神经元,共 $$23,860$$ 个参数。但是我们不会使用所有 $$50,000$$ 幅训练图像。相反,我们只使用前 $$1000$$ 幅图像。使用这个受限的集合,会让泛化的问题突显。按照同样的方式,使用交叉熵代价函数,学习率设置为 $$\eta=0.5$$ 而 minibatch 大小设置为 $$10$$。不过这里我们训练回合设置为 $$400$$,比前面的要多很多,因为我们只用了少量的训练样本。我们现在使用 network2 来研究代价函数改变的情况:

>>> import mnist_loader 
>>> training_data, validation_data, test_data = \
... mnist_loader.load_data_wrapper()
>>> import network2 
>>> net = network2.Network([784, 30, 10], cost=network2.CrossEntropyCost) 
>>> net.large_weight_initializer()
>>> net.SGD(training_data[:1000], 400, 10, 0.5, evaluation_data=test_data,
... monitor_evaluation_accuracy=True, monitor_training_cost=True)

使用上面的结果,我们可以画出代价函数变化的情况:


这看起来令人振奋,因为代价函数有一个光滑的下降,跟我们预期一致。注意,我只是展示了 $$200$$ 到 $$399$$ 回合的情况。这给出了很好的近距离理解训练后期的情况,这也是出现有趣现象的地方。

让我们看看分类准确度在测试集上的表现:


这里我还是聚焦到了后面的过程。 在前 $$200$$ 回合(图中没有显示)准确度提升到了 82%。然后学习逐渐变缓。最终,在 $$280$$ 回合左右分类准确度就停止了增长。后面的回合,仅仅看到了在 $$280$$ 回合准确度周围随机的震荡。将这幅图和前面的图进行对比,和训练数据相关的代价函数持续平滑下降。如果我们只看哪个代价,我们会发现模型的表现变得“更好”。但是测试准确度展示了提升只是一种假象。就像费米不大喜欢的那个模型一样,我们的网络在 $$280$$ 回合后就不在能够繁华到测试数据上。所以这种学习不大有用。也可以说网络在 $$280$$ 后就过匹配(或者过度训练)了。

你可能想知道这里的问题是不是由于我们看的是训练数据的代价,而对比的却是测试数据上的分类准确度导致的。换言之,可能我们这里在进行苹果和橙子的对比。如果我们比较训练数据上的代价和测试数据上的代价,会发生什么,我们是在比较类似的度量么?或者可能我们可以比较在两个数据集上的分类准确度啊?实际上,不管我们使用什么度量的方式尽管,细节会变化,但本质上都是一样的。
让我们来看看测试数据集上的代价变化情况:


我们可以看到测试集上的代价在 $$15$$ 回合前一直在提升,随后越来越差,尽管训练数据机上的代价表现是越来越好的。这其实是另一种模型过匹配的标志。尽管,这里带来了关于我们应当将 $$15$$ 还是 $$280$$ 回合当作是过匹配占主导的时间点的困扰。从一个实践角度,我们真的关心的是提升测试数据集上的分类准确度,而测试集合上的代价不过是分类准确度的一个反应。所以更加合理的选择就是将 $$280$$ 看成是过匹配开始占统治地位的时间点。

另一个过匹配的信号在训练数据上的分类准确度上也能看出来:


准确度一直在提升接近 100%。也就是说,我们的网络能够正确地对所有 $$1000$$ 幅图像进行分类!而在同时,我们的测试准确度仅仅能够达到 82.27%。所以我们的网络实际上在学习训练数据集的特例,而不是能够一般地进行识别。我们的网络几乎是在单纯记忆训练集合,而没有对数字本质进行理解能够泛化到测试数据集上。

过匹配是神经网络的一个主要问题。这在现代网络中特别正常,因为网络权重和偏差数量巨大。为了高效地训练,我们需要一种检测过匹配是不是发生的技术,这样我们不会过度训练。并且我们也想要找到一些技术来降低过匹配的影响。

检测过匹配的明显方法是使用上面的方法——跟踪测试数据集合上的准确度随训练变化情况。如果我们看到测试数据上的准确度不再提升,那么我们就停止训练。当然,严格地说,这其实并非是过匹配的一个必要现象,因为测试集和训练集上的准确度可能会同时停止提升。当然,采用这样的方式是可以阻止过匹配的。

实际上,我们会使用这种方式变形来试验。记得之前我们载入 MNIST 数据的时候有:

>>> import mnist_loader 
>>> training_data, validation_data, test_data = \
... mnist_loader.load_data_wrapper()

到现在我们一直在使用 training_datatest_data,没有用过 validation_datavalidation_data 中包含了 $$10,000$$ 幅数字图像,这些图像和训练数据集中的 $$50,000$$ 幅图像以及测试数据集中的 $$10,000$$ 幅都不相同。我们会使用 validation_data 来防止过匹配。我们会使用和上面应用在 test_data 的策略。我们每个回合都计算在 validation_data 上的分类准确度。一旦分类准确度已经饱和,就停止训练。这个策略被称为 提前停止(Early stopping)。当然,实际应用中,我们不会立即知道什么时候准确度会饱和。相反,我们会一直训练知道我们确信准确度已经饱和。

这里需要一些判定标准来确定什么时候停止。在我前面的图中,将 $$280$$ 回合看成是饱和的地方。可能这有点太悲观了。因为神经网络有时候会训练过程中处在一个平原期,然后又开始提升。如果在 $$400$$ 回合后,性能又开始提升(也许只是一些少量提升),那我也不会诧异。所以,在提前停止中采取一点激进的策略也是可以的。

为何要使用 validation_data 来替代 test_data 防止过匹配问题?实际上,这是一个更为一般的策略的一部分,这个一般的策略就是使用 validation_data 来衡量不同的超参数(如训练回合,学习率,最好的网络架构等等)的选择的效果。我们使用这样方法来找到超参数的合适值。因此,尽管到现在我并没有提及这点,但其实本书前面已经稍微介绍了一些超参数选择的方法。

当然,这对于我们前面关于 validation_data 取代 test_data 来防止过匹配的原因仍旧没有回答。实际上,有一个更加一般的问题,就是为何用validation_data 取代 test_data 来设置更好的超参数?为了理解这点,想想当设置超参数时,我们想要尝试许多不同的超参数选择。如果我们设置超参数是基于 test_data 的话,可能最终我们就会得到过匹配于 test_data 的超参数。也就是说,我们可能会找到那些 符合 test_data 特点的超参数,但是网络的性能并不能够泛化到其他数据集合上。我们借助 validation_data 来克服这个问题。然后一旦获得了想要的超参数,最终我们就使用 test_data 进行准确度测量。这给了我们在 test_data 上结果是一个网络泛化能力真正的度量方式的信心。换言之,你可以将验证集看成是一种特殊的训练数据集能够帮助我们学习好的超参数。这种寻找好的超参数的观点有时候被称为 hold out 方法,因为 validation_data 是从训练集中保留出来的一部分。

在实际应用中,甚至在衡量了测试集上的性能后,我们可能也会改变想法并去尝试另外的方法——也许是一种不同的网络架构——这将会引入寻找新的超参数的的过程。如果我们这样做,难道不会产生过匹配于 test_data 的困境么?我们是不是需要一种潜在无限大的数据集的回归,这样才能够确信模型能够泛化?去除这样的疑惑其实是一个深刻而困难的问题。但是对实际应用的目标,我们不会担心太多。相反,我们会继续采用基于 training_data, validation_data, and test_data 的基本 hold out 方法。

我们已经研究了在使用 $$1,000$$ 幅训练图像时的过匹配问题。那么如果我们使用所有的训练数据会发生什么?我们会保留所有其他的参数都一样($$30$$ 个隐藏元,学习率 $$0.5$$,mini-batch 规模为 $$10$$),但是训练回合为 $$30$$ 次。下图展示了分类准确度在训练和测试集上的变化情况。注意我们使用的测试数据,而不是验证集合,为了让结果看起来和前面的图更方便比较。


如你所见,测试集和训练集上的准确度相比我们使用 $$1,000$$ 个训练数据时相差更小。特别地,在训练数据上的最佳的分类准确度 97.86% 只比测试集上的 95.33% 准确度高一点点。而之前的例子中,这个差距是 17.73%!过匹配仍然发生了,但是已经减轻了不少。我们的网络从训练数据上更好地泛化到了测试数据上。一般来说,最好的降低过匹配的方式之一就是增加训练样本的量。有了足够的训练数据,就算是一个规模非常大的网络也不大容易过匹配。不幸的是,训练数据其实是很难或者很昂贵的资源,所以这不是一种太切实际的选择。

规范化

增加训练样本的数量是一种减轻过匹配的方法。还有其他的一下方法能够减轻过匹配的程度么?一种可行的方式就是降低网络的规模。然而,大的网络拥有一种比小网络更强的潜力,所以这里存在一种应用冗余性的选项。

幸运的是,还有其他的技术能够缓解过匹配,即使我们只有一个固定的网络和固定的训练集合。这种技术就是规范化。本节,我会给出一种最为常用的规范化手段——有时候被称为权重下降(weight decay)或者 L2 规范化。L2 规范化的想法是增加一个额外的项到代价函数上,这个项叫做 规范化 项。下面是规范化交叉熵:


其中第一个项就是常规的交叉熵的表达式。第二个现在加入到就是所有权重的平方的和。然后使用一个因子 $$\lambdas/2n$$ 进行量化调整,其中 $$\lambda > 0$$ 可以成为 规范化参数,而 $$n$$ 就是训练集合的大小。我们会在后面讨论 $$\lambdas$$ 的选择策略。需要注意的是,规范化项里面并不包含偏差。这点我们后面也会在讲述。

当然,对其他的代价函数也可以进行规范化,例如二次代价函数。类似的规范化的形式如下:


两者都可以写成这样:


其中 $$C_0$$ 是原始的代价函数。

直觉地看,规范化的效果是让网络倾向于学习小一点的权重,其他的东西都一样的。大的权重只有能够给出代价函数第一项足够的提升时才被允许。换言之,规范化可以当做一种寻找小的权重和最小化原始的代价函数之间的折中。这两部分之前相对的重要性就由 $$\lambda$$ 的值来控制了:$$\lambda$$ 越小,就偏向于最小化原始代价函数,反之,倾向于小的权重。

现在,对于这样的折中为何能够减轻过匹配还不是很清楚!但是,实际表现表明了这点。我们会在下一节来回答这个问题。但现在,我们来看看一个规范化的确减轻过匹配的例子。

为了构造这个例子,我们首先需要弄清楚如何将随机梯度下降算法应用在一个规范化的神经网络上。特别地,我们需要知道如何计算偏导数 $$\partial C/\partial w$$ 和 $$\partial C/\partial b$$。对公式(87)进行求偏导数得:


$$\partial C_0/\partial w$$ 和 $$\partial C_0/\partial b$$ 可以通过反向传播进行计算,和上一章中的那样。所以我们看到其实计算规范化的代价函数的梯度是很简单的:仅仅需要反向传播,然后加上 $$\frac{\lambda}{n} w$$ 得到所有权重的偏导数。而偏差的偏导数就不要变化,所以梯度下降学习规则不会发生变化:


权重的学习规则就变成:


这其实和通常的梯度下降学习规则相同欧诺个,除了乘了 $$1-\frac{\eta\lambda}{n}$$ 因子。这里就是权重下降的来源。粗看,这样会导致权重会不断下降到 $$0$$。但是实际不是这样的,因为如果在原始代价函数中造成下降的话其他的项可能会让权重增加。

好的,这就是梯度下降工作的原理。那么随机梯度下降呢?正如在没有规范化的随机梯度下降中,我们可以通过平均 minibatch 中 $$m$$ 个训练样本来估计 $$\partial C_0/\partial w$$。因此,为了随机梯度下降的规范化学习规则就变成(参考 方程(20))


其中后面一项是对 minibatch 中的训练样本 $$x$$ 进行求和,而 $$C_x$$ 是对每个训练样本的(无规范化的)代价。这其实和之前通常的随机梯度下降的规则是一样的,除了有一个权重下降的因子 $$1-\frac{\eta\lambda}{n}$$。最后,为了完备性,我给出偏差的规范化的学习规则。这当然是和我们之前的非规范化的情形一致了(参考公式(32))


这里也是对minibatch 中的训练样本 $$x$$ 进行求和的。

让我们看看规范化给网络带来的性能提升吧。这里还会使用有 $$30$$ 个隐藏神经元、minibatch 为 $$10$$,学习率为 $$0.5$$,使用交叉熵的神经网络。然而,这次我们会使用规范化参数为 $$\lambda = 0.1$$。注意在代码中,我们使用的变量名字为 lmbda,这是因为在 Python 中 lambda 是关键字,尤其特定的作用。我也会使用 test_data,而不是 validation_data不过严格地讲,我们应当使用 validation_data的,因为前面已经讲过了。这里我这样做,是因为这会让结果和非规范化的结果对比起来效果更加直接。你可以轻松地调整为 validation_data,你会发现有相似的结果。

>>> import mnist_loader 
>>> training_data, validation_data, test_data = \
... mnist_loader.load_data_wrapper() 
>>> import network2 
>>> net = network2.Network([784, 30, 10], cost=network2.CrossEntropyCost)
>>> net.large_weight_initializer()
>>> net.SGD(training_data[:1000], 400, 10, 0.5,
... evaluation_data=test_data, lmbda = 0.1,
... monitor_evaluation_cost=True, monitor_evaluation_accuracy=True,
... monitor_training_cost=True, monitor_training_accuracy=True)

训练集上的代价函数持续下降,和前面无规范化的情况一样的规律:


但是这里测试集上的准确度是随着回合次数持续增加的:


显然,规范化的使用能够解决过匹配的问题。而且,准确度相当搞了,最高处达到了 87.1%,相较于之前的 82.27%。因此,我们几乎可以确信持续训练会有更加好的结果。实验起来,规范化让网络具有更好的泛化能力,显著地减轻了过匹配的效果。

如果我们换成全部的训练数据进行训练呢?当然,我们之前已经看到过匹配在大规模的数据上其实不是那么明显了。那规范化能不能起到相应的作用呢?保持超参数和之前一样。不过我们这里需要改变规范化参数。原因在于训练数据的大小已经从 $$n=1,000$$ 改成了 $$n=50,000$$,这个会改变权重下降因子 $$1-\frac{\eta\lambda}{n}$$。如果我们持续使用 $$\lambda = 0.1$$ 就会产生很小的权重下降,因此就将规范化的效果降低很多。我们通过将 $$\lambda = 5.0$$ 来补偿这种下降。

好了,来训练网络,重新初始化权重:

>>> net.large_weight_initializer()
>>> net.SGD(training_data, 30, 10, 0.5,
... evaluation_data=test_data, lmbda = 5.0,
... monitor_evaluation_accuracy=True, monitor_training_accuracy=True)

我们得到:


这个结果很不错。第一,我们在测试集上的分类准确度在使用规范化后有了提升,从 95.49% 到 96.49%。这是个很大的进步。第二,我们可以看到在训练数据和测试数据上的结果之间的差距也更小了。这仍然是一个大的差距,不过我们已经显著得到了本质上的降低过匹配的进步。

最后,我们看看在我们使用 $$100$$ 个隐藏元和规范化参数为 $$\lambda = 5.0$$ 相应的测试分类准确度。我不会给出详细分析,就为了有趣,来看看我们使用一些技巧(交叉熵函数和 $$L2$$ 规范化)能够达到多高的准确度。

>>> net = network2.Network([784, 100, 10], cost=network2.CrossEntropyCost)
>>> net.large_weight_initializer()
>>> net.SGD(training_data, 30, 10, 0.5, lmbda=5.0,
... evaluation_data=validation_data,
... monitor_evaluation_accuracy=True)

最终在验证集上的准确度达到了 97.92%。这是比 $$30$$ 个隐藏元的较大飞跃。实际上,稍微改变一点,$$60$$ 回合 $$\eta=0.1$$ 和 $$\lambda = 5.0$$。我们就突破了 98%,达到了 98.04% 的分类准确度。对于 $$152$$ 行代码这个效果还真不错!

我们讨论了作为一种减轻过匹配和提高分类准确度的方式的规范化技术。实际上,这不是仅有的好处。实践表明,在使用不同的(随机)权重初始化进行多次 MNIST 网络训练的时候,我发现无规范化的网络会偶然被限制住,明显困在了代价函数的局部最优值处。结果就是不同的运行会给出相差很大的结果。对比看来,规范化的网络能够提供更容易复制的结果。

为何会这样子?从经验上看,如果代价函数是无规范化的,那么权重向量的长度可能会增长,而其他的东西都保持一样。随着时间的推移,这个会导致权重向量变得非常大。所以会使得权重向困在差不多方向上,因为由于梯度下降的改变当长度很大的时候仅仅会在那个方向发生微小的变化。我相信这个现象让学习算法更难有效地探索权重空间,最终导致很难找到代价函数的最优值。

为何规范化可以帮助减轻过匹配

我们已经看到了规范化在实践中能够减少过匹配了。这是令人振奋的,不过,这背后的原因还不得而知!通常的说法是:小的权重在某种程度上,意味着更低的复杂性,也就给出了一种更简单却更强大的数据解释,因此应该优先选择。这虽然很简短,不过暗藏了一些可能看起来会令人困惑的因素。让我们将这个解释细化,认真地研究一下。现在给一个简单的数据集,我们为其建立模型:


这里我们其实在研究某种真实的现象,$$x$$ 和 $$y$$ 表示真实的数据。我们的目标是训练一个模型来预测 $$y$$ 关于 $$x$$ 的函数。我们可以使用神经网络来构建这个模型,但是我们先来个简单的:用一个多项式来拟合数据。这样做的原因其实是多项式相比神经网络能够让事情变得更加清楚。一旦我们理解了多项式的场景,对于神经网络可以如法炮制。现在,图中有十个点,我们就可以找到唯一的 $$9$$ 阶多项式 $$y=a_0x^9 + a_1x^8 + ... + a_9$$ 来完全拟合数据。下面是多项式的图像:

I won't show the coefficients explicitly, although they are easy to find using a routine such as Numpy's polyfit
. You can view the exact form of the polynomial in the source code for the graph if you're curious. It's the function p(x)
defined starting on line 14 of the program which produces the graph.


这给出了一个完美的拟合。但是我们同样也能够使用线性模型 $$y=2x$$ 得到一个好的拟合效果:


哪个是更好的模型?哪个更可能是真的?还有哪个模型更可能泛化到其他的拥有同样现象的样本上?

这些都是很难回答的问题。实际上,我们如果没有关于现象背后的信息的话,并不能确定给出上面任何一个问题的答案。但是让我们考虑两种可能的情况:(1)$$9$$ 阶多项式实际上是完全描述了真实情况的模型,最终它能够很好地泛化;(2)正确的模型是 $$y=2x$$,但是存在着由于测量误差导致的额外的噪声,使得模型不能够准确拟合。

先验假设无法说出哪个是正确的(或者,如果还有其他的情况出现)。逻辑上讲,这些都可能出现。并且这不是易见的差异。在给出的数据上,两个模型的表现其实是差不多的。但是假设我们想要预测对应于某个超过了图中所有的 $$x$$ 的 $$y$$ 的值,在两个模型给出的结果之间肯定有一个极大的差距,因为 $$9$$ 阶多项式模型肯定会被 $$x^9$$ 主导,而线性模型只是线性的增长。

在科学中,一种观点是我们除非不得已应该追随更简单的解释。当我们找到一个简单模型似乎能够解释很多数据样本的时候,我们都会激动地认为发现了规律!总之,这看起来简单的解决仅仅会是偶然出现的不大可能。我们怀疑模型必须表达出某些关于现象的内在的真理。如上面的例子,线性模型加噪声肯定比多项式更加可能。所以如果简单性是偶然出现的话就很令人诧异。因此我们会认为线性模型加噪声表达除了一些潜在的真理。从这个角度看,多项式模型仅仅是学习到了局部噪声的影响效果。所以尽管多是对于这些特定的数据点表现得很好。模型最终会在未知数据上的泛化上出现问题,所以噪声线性模型具有更强大的预测能力。

让我们从这个观点来看神经网络。假设神经网络大多数有很小的权重,这最可能出现在规范化的网络中。更小的权重意味着网络的行为不会因为我们随便改变了一个输入而改变太大。这会让规范化网络学习局部噪声的影响更加困难。将它看做是一种让单个的证据不会影响网络输出太多的方式。相对的,规范化网络学习去对整个训练集中经常出现的证据进行反应。对比看,大权重的网络可能会因为输入的微小改变而产生比较大的行为改变。所以一个无规范化的网络可以使用大的权重来学习包含训练数据中的噪声的大量信息的复杂模型。简言之,规范化网络受限于根据训练数据中常见的模式来构造相对简单的模型,而能够抵抗训练数据中的噪声的特性影响。我们的想法就是这可以让我们的网络对看到的现象进行真实的学习,并能够根据已经学到的知识更好地进行泛化。

所以,倾向于更简单的解释的想法其实会让我们觉得紧张。人们有时候将这个想法称为“奥卡姆剃刀原则”,然后就会热情地将其当成某种科学原理来应用这个法则。但是,这就不是一个一般的科学原理。也没有任何先验的逻辑原因来说明简单的解释就比更为负责的解释要好。实际上,有时候更加复杂的解释其实是正确的。

让我介绍两个说明复杂正确的例子。在 $$1940$$ 年代,物理学家 Marcel Schein 发布了一个发现新粒子的声明。而他工作的公司,GE,非常欢喜,就广泛地推广这个发现。但是物理学及 Hans Bethe 就有怀疑。Bethe 访问了 Schein,看着那些展示 Schein 的新粒子的轨迹的盘子。但是在每个 plate 上,Bethe 都发现了某个说明数据需要被去除的问题。最后 Schein 展示给 Bethe 一个看起来很好的 plate。Bethe 说这可能就是一个统计上的侥幸。Schein 说,“使得,但是这个可能就是统计学,甚至是根据你自己的公式,也就是 $$1/5$$ 的概率。” Bethe 说:“但我们已经看过了这 $$5$$ 个plate 了”。最终,Schein 说:“但是在我的plate中,每个好的plate,每个好的场景,你使用了不同的理论(说它们是新的粒子)进行解释,而我只有一种假设来解释所有的 plate。” Bethe 回答说,“在你和我的解释之间的唯一差别就是你的是错的,而我所有的观点是正确的。你单一的解释是错误的,我的多重解释所有都是正确的。”后续的工作证实了,Bethe 的想法是正确的而 Schein 粒子不再正确。

注意:这一段翻译得很不好,请参考原文

第二个例子,在 $$1859$$ 年,天文学家 Urbain Le Verrier 观察到水星并没有按照牛顿万有引力给出的轨迹进行运转。与牛顿力学只有很小的偏差,那时候一些解释就是牛顿力学需要一些微小的改动了。在 $$1916$$ 年爱因斯坦证明偏差用他的广义相对论可以解释得更好,这是一种和牛顿重力体系相差很大的理论,基于更复杂的数学。尽管引入了更多的复杂性,现如今爱因斯坦的解释其实是正确的,而牛顿力学即使加入一些调整,仍旧是错误的。这部分因为我们知道爱因斯坦的理论不仅仅解释了这个问题,还有很多其他牛顿力学无法解释的问题也能够完美解释。另外,令人印象深刻的是,爱因斯坦的理论准确地给出了一些牛顿力学没能够预测到的显现。但是这些令人印象深刻的现象其实在先前的时代是观测不到的。如果一个人仅仅通过简单性作为判断合理模型的基础,那么一些牛顿力学的改进理论可能会看起来更加合理一些。

从这些故事中可以读出三点。第一,确定两种解释中哪个“更加简单”其实是一件相当微妙的工作。第二,即使我们可以做出这样一个判断,简单性也是一个使用时需要相当小心的指导!第三,对模型真正的测试不是简单性,而是它在新场景中对新的活动中的预测能力。

所以,我们应当时时记住这一点,规范化的神经网络常常能够比非规范化的泛化能力更强,这只是一种实验事实(empirical fact)。所以,本书剩下的内容,我们也会频繁地使用规范化技术。我已经在上面讲过了为何现在还没有一个人能够发展出一整套具有说服力的关于规范化可以帮助网络泛化的理论解释。实际上,研究者们不断地在写自己尝试不同的规范化方法,然后看看哪种表现更好,尝试理解为何不同的观点表现的更好。所以你可以将规范化看做某种任意整合的技术。尽管其效果不错,但我们并没有一套完整的关于所发生情况的理解,仅仅是一些不完备的启发式规则或者经验。

这里也有更深的问题,这个问题也是有关科学的关键问题——我们如何泛化。规范化能够给我们一种计算上的魔力帮助神经网络更好地泛化,但是并不会带来原理上理解的指导,甚至不会告诉我们什么样的观点才是最好的。

这个问题要追溯到归纳问题,最先由苏格兰哲学家大卫 休谟在 "An Enquiry Concerning Human Understanding" (1748) 中提出。在现代机器学习领域中归纳问题被 David Wolpert 和 William Macready 描述成无免费午餐定理

这实在是令人困扰,因为在日常生活中,我们人类在泛化上表现很好。给一个儿童几幅大象的图片,他就能快速地学会认识其他的大象。当然,他们偶尔也会搞错,很可能将一只犀牛误认为大象,但是一般说来,这个过程会相当准确。所以我们有个系统——人的大脑——拥有超大量的自由变量。在受到仅仅少量的训练图像后,系统学会了在其他图像的推广。某种程度上,我们的大脑的规范化做得特别好!怎么做的?现在还不得而知。我期望若干年后,我们能够发展出更加强大的技术来规范化神经网络,最终这些技术会让神经网络甚至在小的训练集上也能够学到强大的泛化能力。

实际上,我们的网络已经比我们预先期望的要好一些了。拥有 $$100$$ 个隐藏元的网络会有接近 $$80,000$$ 个参数。我们的训练集仅仅有 $$50,000$$ 幅图像。这好像是用一个 $$80,000$$ 阶的多项式来拟合 $$50,000$$ 个数据点。我们的网络肯定会过匹配得很严重。但是,这样的网络实际上却泛化得很好。为什么?这一点并没有很好滴理解。这里有个猜想:梯度下降学习的动态有一种自规范化的效应。这真是一个意料之外的巧合,但也带来了对于这种现象本质无知的不安。不过,我们还是会在后面依照这种实践的观点来应用规范化技术的。
神经网络也是由于这点表现才更好一些。

现在我们回到前面留下来的一个细节:L2 规范化没有限制偏差,以此作为本节的结论。当然了,对规范化的过程稍作调整就可以对偏差进行规范了。实践看来,做出这样的调整并不会对结果改变太多,所以,在某种程度上,对不对偏差进行规范化其实就是一种习惯了。然而,需要注意的是,有一个大的偏差并不会像大的权重那样会让神经元对输入太过敏感。所以我们不需要对大的偏差所带来的学习训练数据的噪声太过担心。同时,允许大的偏差能够让网络更加灵活——因为,大的偏差让神经元更加容易饱和,这有时候是我们所要达到的效果。所以,我们通常不会对偏差进行规范化。

规范化的其他技术

除了 L2 外还有很多规范化技术。实际上,正是由于数量众多,我这里也不回将所有的都列举出来。在本节,我简要地给出三种减轻过匹配的其他的方法:L1 规范化、dropout 和人工增加训练样本。我们不会像上面介绍得那么深入。其实,目的只是想让读者熟悉这些主要的思想,然后来体会一下规范化技术的多样性。

L1 规范化:这个方法其实是在代价函数上加上一个权重绝对值的和:


直觉上看,这和 L2 规范化相似,惩罚大的权重,倾向于让网络的权重变小。当然,L1 规范化和 L2 规范化并不相同,所以我们不应该期望 L1 规范化是进行同样的行为。让我们来看看试着理解使用 L1 规范化和 L2 规范化所不同的地方。

首先,我们会研究一下代价函数的偏导数。对(95)求导我们有:


其中 $$sgn(w)$$ 就是 $$w$$ 的正负号。使用这个表达式,我们可以轻易地对反向传播进行修改从而使用基于 L1 规范化的随机梯度下降进行学习。对 L1 规范化的网络进行更新的规则就是


其中和往常一样,我们可以用 minibatch 的均值来估计 $$\partial C_0/\partial w$$。对比公式(93)的 L2 规范化,


在两种情形下,规范化的效果就是缩小权重。这和我们想要让权重不会太大的直觉目标相符。在 L1 规范化中,权重按照一个接近 $$0$$ 的常量进行缩小。在 L2 规范化中,权重同按照一个和 $$w$$ 成比例的量进行缩小的。所以,当一个特定的权重绝对值 $$|w|$$很大时,L1 规范化缩小得远比 L2 规范化要小得多。而一个特定的权重绝对值 $$|w|$$很小时,L1 规范化权值要比 L2 规范化缩小得更大。最终的结果就是:L1 规范化倾向于聚集网络的权重在相对少量的高重要度连接上,而其他权重就会被驱使向 $$0$$ 接近。

我在上面的讨论中其实忽略了一个问题——在 $$w=0$$ 的时候,偏导数 $$\partial C/\partial w$$ 未定义。原因在于函数 $$|w|$$ 在 $$w=0$$ 时有个直角,事实上,导数是不存在的。不过也没有关系。我们下面要做的就是应用无规范化的通常的梯度下降的规则在 $$w=0$$ 处。这应该不会有什么问题,直觉上看,规范化的效果就是缩小权重,显然,不能对一个已经是 $$0$$ 的权重进行缩小了。更准确地说,我们将会使用方程(96)(97)并约定 $$sgn(0)=0$$。这样就给出了一种紧致的规则来进行采用 L1 规范化的随机梯度下降学习。

Dropout :Dropout 是一种相当激进的技术。和 L1、L2 规范化不同,dropout 并不依赖对代价函数的变更。而是,在 dropout 中,我们改变了网络本身。让我在给出为何工作的原理之前描述一下 dropout 基本的工作机制和所得到的结果。

假设我们尝试训练一个网络:


特别地,假设我们有一个训练数据 $$x$$ 和 对应的目标输出 $$y$$。通常我们会通过在网络中前向传播 $$x$$ ,然后进行反向传播来确定对梯度的共现。使用 dropout,这个过程就改了。我们会从随机(临时)地删除网络中的一半的神经元开始,让输入层和输出层的神经元保持不变。在此之后,我们会得到最终的网络。注意那些被 dropout 的神经元,即那些临时性删除的神经元,用虚圈表示在途中:


我们前向传播输入,通过修改后的网络,然后反向传播结果,同样通过这个修改后的网络。在 minibatch 的若干样本上进行这些步骤后,我们对那些权重和偏差进行更新。然后重复这个过程,首先重置 dropout 的神经元,然后选择新的随机隐藏元的子集进行删除,估计对一个不同的minibatch的梯度,然后更新权重和偏差。

通过不断地重复,我们的网络会学到一个权重和偏差的集合。当然,这些权重和偏差也是在一般的隐藏元被丢弃的情形下学到的。当我们实际运行整个网络时,是指两倍的隐藏元将会被激活。为了补偿这个,我们将从隐藏元出去的权重减半了。

这个 dropout 过程可能看起来奇怪和ad hoc。为什么我们期待这样的方法能够进行规范化呢?为了解释所发生的事,我希望你停下来想一下没有 dropout 的训练方式。特别地,想象一下我们训练几个不同的神经网络,使用的同一个训练数据。当然,网络可能不是从同一初始状态开始的,最终的结果也会有一些差异。出现这种情况时,我们可以使用一些平均或者投票的方式来确定接受哪个输出。例如,如果我们训练了五个网络,其中三个被分类当做是 $$3$$,那很可能它就是 $$3$$。另外两个可能就犯了错误。这种平均的方式通常是一种强大(尽管代价昂贵)的方式来减轻过匹配。原因在于不同的网络可能会以不同的方式过匹配,平均法可能会帮助我们消除那样的过匹配。

那么这和 dropout 有什么关系呢?启发式地看,当我们丢掉不同的神经元集合时,有点像我们在训练不同的神经网络。所以,dropout 过程就如同大量不同网络的效果的平均那样。不同的网络以不同的方式过匹配了,所以,dropout 的网络会减轻过匹配。

一个相关的启发式解释在早期使用这项技术的论文中曾经给出:“因为神经元不能依赖其他神经元特定的存在,这个技术其实减少了复杂的互适应的神经元。所以,强制要学习那些在神经元的不同随机子集中更加健壮的特征。”换言之,如果我们就爱那个神经网络看做一个进行预测的模型的话,我们就可以将 dropout 看做是一种确保模型对于证据丢失健壮的方式。这样看来,dropout 和 L1、L2 规范化也是有相似之处的,这也倾向于更小的权重,最后让网络对丢失个体连接的场景更加健壮。

当然,真正衡量 dropout 的方式在提升神经网络性能上应用得相当成功。原始论文介绍了用来解决很多不同问题的技术。对我们来说,特别感兴趣的是他们应用 dropout 在 MNIST 数字分类上,使用了一个和我们之前介绍的那种初级的前向神经网络。这篇文章关注到最好的结果是在测试集上去得到 98.4% 的准确度。他们使用dropout 和 L2 规范化的组合将其提高到了 98.7%。类似重要的结果在其他不同的任务上也取得了一定的成效。dropout 已经在过匹配问题尤其突出的训练大规模深度网络中。

人工扩展训练数据:我们前面看到了 MNIST 分类准确度在我们使用 $$1,000$$ 幅训练图像时候下降到了 $$80$$ 年代的准确度。这种情况并不奇怪,因为更少的训练数据意味着我们的网络所接触到较少的人类手写的数字中的变化。让我们训练 $$30$$ 个隐藏元的网络,使用不同的训练数据集,来看看性能的变化情况。我们使用 minibatch 大小为 $$10$$,学习率是 $$\eta=0.5$$,规范化参数是 $$\lambda=5.0$$,交叉熵代价函数。我们在全部训练数据集合上训练 30 个回合,然后会随着训练数据量的下降而成比例变化回合数。为了确保权重下降因子在训练数据集上相同,我们会在全部训练集上使用规范化参数为 $$\lambda = 5.0$$,然后在使用更小的训练集的时候成比例地下降 $$\lambda$$ 值。

This and the next two graph are produced with the program more_data.py.


如你所见,分类准确度在使用更多的训练数据时提升了很大。根据这个趋势的话,提升会随着更多的数据而不断增加。当然,在训练的后期我们看到学习过程已经进入了饱和状态。然而,如果我们使用对数作为横坐标的话,可以重画此图如下:


这看起来到了后面结束的地方,增加仍旧明显。这表明如果我们使用大量更多的训练数据——不妨设百万或者十亿级的手写样本——那么,我们可能会得到更好的性能,即使是用这样的简单网络。

获取更多的训练样本其实是很重要的想法。不幸的是,这个方法代价很大,在实践中常常是很难达到的。不过,还有一种方法能够获得类似的效果,那就是进行人工的样本扩展。假设我们使用一个 $$5$$ 的训练样本,


将其进行旋转,比如说 $$15$$°:


这还是会被设别为同样的数字的。但是在像素层级这和任何一幅在 MNIST 训练数据中的图像都不相同。所以将这样的样本加入到训练数据中是很可能帮助我们学习有关手写数字更多知识的方法。而且,显然,我们不会就只对这幅图进行人工的改造。我们可以在所有的 MNIST 训练样本上通过和多小的旋转扩展训练数据,然后使用扩展后的训练数据来提升我们网络的性能。

这个想法非常强大并且已经被广发应用了。让我们看看一些在 MNIST 上使用了类似的方法进行研究成果。其中一种他们考虑的网络结构其实和我们已经使用过的类似——一个拥有 800 个隐藏元的前驱神经网络,使用了交叉熵代价函数。在标准的 MNIST 训练数据上运行这个网络,得到了 98.4% 的分类准确度,其中使用了不只是旋转还包括转换和扭曲。通过在这个扩展后的数据集上的训练,他们提升到了 98.9% 的准确度。然后还在“弹性扭曲(elastic distortion)”的数据上进行了实验,这是一种特殊的为了模仿手部肌肉的随机抖动的图像扭曲方法。通过使用弹性扭曲扩展的数据,他们最终达到了 99.3% 的分类准确度。他们通过展示训练数据的所有类型的变体来扩展了网络的经验。

Best Practices for Convolutional Neural Networks Applied to Visual Document Analysis, by Patrice Simard, Dave Steinkraus, and John Platt (2003).

这个想法的变体也可以用在提升手写数字识别之外不同学习任务上的性能。一般就是通过应用反映真实世界变化的操作来扩展训练数据。找到这些方法其实并不困难。例如,你要构建一个神经网络来进行语音识别。我们人类甚至可以在有背景噪声的情况下识别语音。所以你可以通过增加背景噪声来扩展你的训练数据。我们同样能够对其进行加速和减速来获得相应的扩展数据。所以这是另外的一些扩展训练数据的方法。这些技术并不总是有用——例如,其实与其在数据中加入噪声,倒不如先对数据进行噪声的清理,这样可能更加有效。当然,记住可以进行数据的扩展,寻求应用的机会还是相当有价值的一件事。

练习

  • 正如上面讨论的那样,一种扩展 MNIST 训练数据的方式是用一些微小的旋转。如果我们允许过大的旋转,则会出现什么状况呢?

大数据的旁白和对分类准确度的影响:让我们看看神经网络准确度随着训练集大小变化的情况:


假设,我们使用别的什么方法来进行分类。例如,我们使用 SVM。正如第一章介绍的那样,不要担心你熟不熟悉 SVM,我们不进行深入的讨论。下面是 SVM 模型的准确度随着训练数据集的大小变化的情况:


可能第一件让你吃惊的是神经网络在每个训练规模下性能都超过了 SVM。这很好,尽管你对细节和原理可能不太了解,因为我们只是直接从 scikit-learn 中直接调用了这个方法,而对神经网络已经深入讲解了很多。更加微妙和有趣的现象其实是如果我们训练 SVM 使用 $$50,000$$ 幅图像,实际上 SVM 已经能够超过我们使用 $$5,000$$ 幅图像的准确度。换言之,更多的训练数据可以补偿不同的机器学习算法的差距。

还有更加有趣的现象也出现了。假设我们试着用两种机器学习算法去解决问题,算法 $$A$$ 和算法 $$B$$。有时候出现,算法 $$A$$ 在一个训练集合上超过 算法 $$B$$,却在另一个训练集上弱于算法 $$B$$。上面我们并没有看到这个情况——因为这要求两幅图有交叉的点——这里并没有。对“算法 A 是不是要比算法 $$B$$ 好?”正确的反应应该是“你在使用什么训练集合?”

在进行开发时或者在读研究论文时,这都是需要记住的事情。很多论文聚焦在寻找新的技术来给出标准数据集上更好的性能。“我们的超赞的技术在标准测试集 $$Y$$ 上给出了百分之 $$X$$ 的性能提升。”这是通常的研究声明。这样的声明通常比较有趣,不过也必须被理解为仅仅在特定的训练数据机上的应用效果。那些给出基准数据集的人们会拥有更大的研究经费支持,这样能够获得更好的训练数据。所以,很可能他们由于超赞的技术的性能提升其实在更大的数据集合上就丧失了。换言之,人们标榜的提升可能就是历史的偶然。所以需要记住的特别是在实际应用中,我们想要的是更好的算法和更好的训练数据。寻找更好的算法很重,不过需要确保你在此过程中,没有放弃对更多更好的数据的追求。

问题

  • 研究问题:我们的机器学习算法在非常大的数据集上如何进行?对任何给定的算法,其实去定义一个随着训练数据规模变化的渐近的性能是一种很自然的尝试。一种简单粗暴的方法就是简单地进行上面图中的趋势分析,然后将图像推进到无穷大。而对此想法的反驳是曲线本身会给出不同的渐近性能。你能够找到拟合某些特定类别曲线的理论上的验证方法吗?如果可以,比较不同的机器学习算法的渐近性能。

总结:我们现在已经介绍完了过匹配和规范化。当然,我们重回这个问题。正如我们前面讲过的那样,尤其是计算机越来越强大,我们有训练更大的网络的能力时。过匹配是神经网络中一个主要的问题。我们有迫切的愿望来开发出强大的规范化技术来减轻过匹配,所以,这也是当前极其热门的研究方向之一。

===============================================================================================================================

权重初始化


创建了神经网络后,我们需要进行权重和偏差的初始化。到现在,我们一直是根据在第一章中介绍的那样进行初始化。提醒你一下,之前的方式就是根据独立的均值为 $$0$$,标准差为 $$1$$ 的高斯随机变量随机采样作为权重和偏差的初始值。这个方法工作的还不错,但是非常 ad hoc,所以我们需要寻找一些更好的方式来设置我们网络的初始化权重和偏差,这对于帮助网络学习速度的提升很有价值。

结果表明,我们可以比使用正规化的高斯分布效果更好。为什么?假设我们使用一个很多的输入神经元,比如说 $$1000$$。假设,我们已经使用正规化的高斯分布初始化了连接第一隐藏层的权重。现在我将注意力集中在这一层的连接权重上,忽略网络其他部分:


我们为了简化,假设,我们使用训练样本 x 其中一半的神经元值为 $$0$$,另一半为 $$1$$。下面的观点也是可以更加广泛地应用,但是你可以从特例中获得背后的思想。让我们考虑带权和 $$z=\sum_j w_j x_j + b$$ 的隐藏元输入。其中 $$500$$ 个项消去了,因为对应的输入 $$x_j=0$$。所以 $$z$$ 是 $$501$$ 个正规化的高斯随机变量的和,包含 $$500$$ 个权重项和额外的 $$1$$ 个偏差项。因此 $$z$$ 本身是一个均值为 $$0$$ 标准差为 $$\sqrt{501}\approx 22.4$$ 的分布。$$z$$ 其实有一个非常宽的高斯分布,不是非常尖的形状:


尤其是,我们可以从这幅图中看出 $$|z|$$ 会变得非常的大,比如说 $$z\gg1$$ 或者 $$z\ll 1$$。如果是这样,输出 $$\sigma(z)$$ 就会接近 $$1$$ 或者 $$0$$。也就表示我们的隐藏元会饱和。所以当出现这样的情况时,在权重中进行微小的调整仅仅会给隐藏元的激活值带来极其微弱的改变。而这种微弱的改变也会影响网络中剩下的神经元,然后会带来相应的代价函数的改变。结果就是,这些权重在我们进行梯度下降算法时会学习得非常缓慢。这其实和我们前面讨论的问题差不多,前面的情况是输出神经元在错误的值上饱和导致学习的下降。我们之前通过代价函数的选择解决了前面的问题。不幸的是,尽管那种方式在输出神经元上有效,但对于隐藏元的饱和却一点作用都没有。

我已经研究了第一隐藏层的权重输入。当然,类似的论断也对后面的隐藏层有效:如果权重也是用正规化的高斯分布进行初始化,那么激活值将会接近 $$0$$ 或者 $$1$$,学习速度也会相当缓慢。

还有可以帮助我们进行更好地初始化么,能够避免这种类型的饱和,最终避免学习速度的下降?假设我们有一个有 $$n_{in}$$ 个输入权重的神经元。我们会使用均值为 $$0$$ 标准差为 $$1/\sqrt{n_{in}}$$ 的高斯分布初始化这些权重。也就是说,我们会向下挤压高斯分布,让我们的神经元更不可能饱和。我们会继续使用均值为 $$0$$ 标准差为 $$1$$ 的高斯分布来对偏差进行初始化,后面会告诉你原因。有了这些设定,带权和 $$z=\sum_j w_j x_j + b$$ 仍然是一个均值为 $$0$$ 不过有很陡的峰顶的高斯分布。假设,我们有 $$500$$ 个值为 $$0$$ 的输入和$$500$$ 个值为 $$1$$ 的输入。那么很容证明 $$z$$ 是服从均值为 $$0$$ 标准差为 $$\sqrt{3/2} = 1.22$$ 的高斯分布。这图像要比以前陡得多,所以即使我已经对横坐标进行压缩为了进行更直观的比较:


这样的一个神经元更不可能饱和,因此也不大可能遇到学习速度下降的问题。

练习

  • 验证 $$z=\sum_j w_j x_j + b$$ 标准差为 $$\sqrt{3/2}$$。下面两点可能会有帮助:(a) 独立随机变量的和的方差是每个独立随即便方差的和;(b)方差是标准差的平方。

我在上面提到,我们使用同样的方式对偏差进行初始化,就是使用均值为 $$0$$ 标准差为 $$1$$ 的高斯分布来对偏差进行初始化。这其实是可行的,因为这样并不会让我们的神经网络更容易饱和。实际上,其实已经避免了饱和的问题的话,如何初始化偏差影响不大。有些人将所有的偏差初始化为 $$0$$,依赖梯度下降来学习合适的偏差。但是因为差别不是很大,我们后面还会按照前面的方式来进行初始化。

让我们在 MNIST 数字分类任务上比较一下新旧两种权重初始化方式。同样,还是使用 $$30$$ 个隐藏元,minibatch 的大小为 $$30$$,规范化参数 $$\lambda=5.0$$,然后是交叉熵代价函数。我们将学习率从 $$\eta=0.5$$ 调整到 $$0.1$$,因为这样会让结果在图像中表现得更加明显。我们先使用旧的初始化方法训练:

>>> import mnist_loader
>>> training_data, validation_data, test_data = \
... mnist_loader.load_data_wrapper()
>>> import network2
>>> net = network2.Network([784, 30, 10], cost=network2.CrossEntropyCost)
>>> net.large_weight_initializer()
>>> net.SGD(training_data, 30, 10, 0.1, lmbda = 5.0,
... evaluation_data=validation_data, 
... monitor_evaluation_accuracy=True)

我们也使用新方法来进行权重的初始化。这实际上还要更简单,因为 network2's 默认方式就是使用新的方法。这意味着我们可以丢掉 net.large_weight_initializer() 调用:

>>> net = network2.Network([784, 30, 10], cost=network2.CrossEntropyCost)
>>> net.SGD(training_data, 30, 10, 0.1, lmbda = 5.0,
... evaluation_data=validation_data, 
... monitor_evaluation_accuracy=True)

将结果用图展示出来,就是:


两种情形下,我们在 96% 的准确度上重合了。最终的分类准确度几乎完全一样。但是新的初始化技术带来了速度的提升。在第一种初始化方式的分类准确度在 87% 一下,而新的方法已经几乎达到了 93%。看起来的情况就是我们新的关于权重初始化的方式将训练带到了一个新的境界,让我们能够更加快速地得到好的结果。同样的情况在 $$100$$ 个神经元的设定中也出现了:


在这个情况下,两个曲线并没有重合。然而,我做的实验发现了其实就在一些额外的回合后(这里没有展示)准确度其实也是几乎相同的。所以,基于这些实验,看起来提升的权重初始化仅仅会加快训练,不会改变网络的性能。然而,在第四张,我们会看到一些例子里面使用 $$1/\sqrt{n_{in}}$$ 权重初始化的长期运行的结果要显著更优。因此,不仅仅能够带来训练速度的加快,有时候在最终性能上也有很大的提升。

$$1/\sqrt{n_{in}}$$ 的权重初始化方法帮助我们提升了神经网络学习的方式。其他的权重初始化技术同样也有,很多都是基于这个基本的思想。我不会在这里给出其他的方法,因为 $$1/\sqrt{n_{in}}$$ 已经可以工作得很好了。如果你对另外的思想感兴趣,我推荐你看看在 $$2012$$ 年的 Yoshua Bengio 的论文的 $$14$$ 和 $$15$$ 页,以及相关的参考文献。

Practical Recommendations for Gradient-Based Training of Deep Architectures, by Yoshua Bengio (2012).

问题

  • 将规范化和改进的权重初始化方法结合使用 L2 规范化有时候会自动给我们一些类似于新的初始化方法的东西。假设我们使用旧的初始化权重的方法。考虑一个启发式的观点:(1)假设$$\lambda$$ 不太小,训练的第一回合将会几乎被权重下降统治。;(2)如果 $$\eta\lambda \ll n$$,权重会按照因子 $$exp(-\eta\lambda/m)$$ 每回合下降;(3)假设 $$\lambda$$ 不太大,权重下降会在权重降到 $$1/\sqrt{n}$$ 的时候保持住,其中 $$n$$ 是网络中权重的个数。用论述这些条件都已经满足本节给出的例子。

再看手写识别问题:代码


让我们实现本章讨论过的这些想法。我们将写出一个新的程序,network2.py,这是一个对第一章中开发的 network.py 的改进版本。如果你没有仔细看过 network.py,那你可能会需要重读前面关于这段代码的讨论。仅仅 $$74$$ 行代码,也很易懂。

network.py 一样,主要部分就是 Network 类了,我们用这个来表示神经网络。使用一个 sizes 的列表来对每个对应层进行初始化,默认使用交叉熵作为代价 cost 参数:

class Network(object):

    def __init__(self, sizes, cost=CrossEntropyCost):
        self.num_layers = len(sizes)
        self.sizes = sizes
        self.default_weight_initializer()
        self.cost=cost

__init__ 方法的和 network.py 中一样,可以轻易弄懂。但是下面两行是新的,我们需要知道他们到底做了什么。

我们先看看 default_weight_initializer 方法,使用了我们新式改进后的初始化权重方法。如我们已经看到的,使用了均值为 $$0$$ 而标准差为 $$1/\sqrt{n}$$,$$n$$ 为对应的输入连接个数。我们使用均值为 $$0$$ 而标准差为 $$1$$ 的高斯分布来初始化偏差。下面是代码:

def default_weight_initializer(self):
        self.biases = [np.random.randn(y, 1) for y in self.sizes[1:]]
        self.weights = [np.random.randn(y, x)/np.sqrt(x) 
                        for x, y in zip(self.sizes[:-1], self.sizes[1:])]

为了理解这段代码,需要知道 np 就是进行线性代数运算的 Numpy 库。我们在程序的开头会 import Numpy。同样我们没有对第一层的神经元的偏差进行初始化。因为第一层其实是输入层,所以不需要引入任何的偏差。我们在 network.py 中做了完全一样的事情。

作为 default_weight_initializer 的补充,我们同样包含了一个 large_weight_initializer 方法。这个方法使用了第一章中的观点初始化了权重和偏差。代码也就仅仅是和default_weight_initializer差了一点点了:

def large_weight_initializer(self):
        self.biases = [np.random.randn(y, 1) for y in self.sizes[1:]]
        self.weights = [np.random.randn(y, x) 
                        for x, y in zip(self.sizes[:-1], self.sizes[1:])]

我将 larger_weight_initializer 方法包含进来的原因也就是使得跟第一章的结果更容易比较。我并没有考虑太多的推荐使用这个方法的实际情景。

初始化方法 __init__ 中的第二个新的东西就是我们初始化了 cost 属性。为了理解这个工作的原理,让我们看一下用来表示交叉熵代价的类:

class CrossEntropyCost(object):

    @staticmethod
    def fn(a, y):
        return np.sum(np.nan_to_num(-y*np.log(a)-(1-y)*np.log(1-a)))

    @staticmethod
    def delta(z, a, y):
        return (a-y)

让我们分解一下。第一个看到的是:即使使用的是交叉熵,数学上看,就是一个函数,这里我们用 Python 的类而不是 Python 函数实现了它。为什么这样做呢?答案就是代价函数在我们的网络中扮演了两种不同的角色。明显的角色就是代价是输出激活值 $$a$$ 和目标输出 $$y$$ 差距优劣的度量。这个角色通过 CrossEntropyCost.fn 方法来扮演。(注意,np.nan_to_num 调用确保了 Numpy 正确处理接近 $$0$$ 的对数值)但是代价函数其实还有另一个角色。回想第二章中运行反向传播算法时,我们需要计算网络输出误差,$$\delta^L$$。这种形式的输出误差依赖于代价函数的选择:不同的代价函数,输出误差的形式就不同。对于交叉熵函数,输出误差就如公式(66)所示:


所以,我们定义了第二个方法,CrossEntropyCost.delta,目的就是让网络知道如何进行输出误差的计算。然后我们将这两个组合在一个包含所有需要知道的有关代价函数信息的类中。

类似地,network2.py 还包含了一个表示二次代价函数的类。这个是用来和第一章的结果进行对比的,因为后面我们几乎都在使用交叉函数。代码如下。QuadraticCost.fn 方法是关于网络输出 $$a$$ 和目标输出 $$y$$ 的二次代价函数的直接计算结果。由 QuadraticCost.delta 返回的值就是二次代价函数的误差。

class QuadraticCost(object):

    @staticmethod
    def fn(a, y):
        return 0.5*np.linalg.norm(a-y)**2

    @staticmethod
    def delta(z, a, y):
        return (a-y) * sigmoid_prime(z)

现在,我们理解了 network2.pynetwork.py 两个实现之间的主要差别。都是很简单的东西。还有一些更小的变动,下面我们会进行介绍,包含 L2 规范化的实现。在讲述规范化之前,我们看看 network2.py 完整的实现代码。你不需要太仔细地读遍这些代码,但是对整个结构尤其是文档中的内容的理解是非常重要的,这样,你就可以理解每段程序所做的工作。当然,你也可以随自己意愿去深入研究!如果你迷失了理解,那么请读读下面的讲解,然后再回到代码中。不多说了,给代码:

"""network2.py
~~~~~~~~~~~~~~

An improved version of network.py, implementing the stochastic
gradient descent learning algorithm for a feedforward neural network.
Improvements include the addition of the cross-entropy cost function,
regularization, and better initialization of network weights.  Note
that I have focused on making the code simple, easily readable, and
easily modifiable.  It is not optimized, and omits many desirable
features.

"""

#### Libraries
# Standard library
import json
import random
import sys

# Third-party libraries
import numpy as np


#### Define the quadratic and cross-entropy cost functions

class QuadraticCost(object):

    @staticmethod
    def fn(a, y):
        """Return the cost associated with an output ``a`` and desired output
        ``y``.

        """
        return 0.5*np.linalg.norm(a-y)**2

    @staticmethod
    def delta(z, a, y):
        """Return the error delta from the output layer."""
        return (a-y) * sigmoid_prime(z)


class CrossEntropyCost(object):

    @staticmethod
    def fn(a, y):
        """Return the cost associated with an output ``a`` and desired output
        ``y``.  Note that np.nan_to_num is used to ensure numerical
        stability.  In particular, if both ``a`` and ``y`` have a 1.0
        in the same slot, then the expression (1-y)*np.log(1-a)
        returns nan.  The np.nan_to_num ensures that that is converted
        to the correct value (0.0).

        """
        return np.sum(np.nan_to_num(-y*np.log(a)-(1-y)*np.log(1-a)))

    @staticmethod
    def delta(z, a, y):
        """Return the error delta from the output layer.  Note that the
        parameter ``z`` is not used by the method.  It is included in
        the method's parameters in order to make the interface
        consistent with the delta method for other cost classes.

        """
        return (a-y)


#### Main Network class
class Network(object):

    def __init__(self, sizes, cost=CrossEntropyCost):
        """The list ``sizes`` contains the number of neurons in the respective
        layers of the network.  For example, if the list was [2, 3, 1]
        then it would be a three-layer network, with the first layer
        containing 2 neurons, the second layer 3 neurons, and the
        third layer 1 neuron.  The biases and weights for the network
        are initialized randomly, using
        ``self.default_weight_initializer`` (see docstring for that
        method).

        """
        self.num_layers = len(sizes)
        self.sizes = sizes
        self.default_weight_initializer()
        self.cost=cost

    def default_weight_initializer(self):
        """Initialize each weight using a Gaussian distribution with mean 0
        and standard deviation 1 over the square root of the number of
        weights connecting to the same neuron.  Initialize the biases
        using a Gaussian distribution with mean 0 and standard
        deviation 1.

        Note that the first layer is assumed to be an input layer, and
        by convention we won't set any biases for those neurons, since
        biases are only ever used in computing the outputs from later
        layers.

        """
        self.biases = [np.random.randn(y, 1) for y in self.sizes[1:]]
        self.weights = [np.random.randn(y, x)/np.sqrt(x)
                        for x, y in zip(self.sizes[:-1], self.sizes[1:])]

    def large_weight_initializer(self):
        """Initialize the weights using a Gaussian distribution with mean 0
        and standard deviation 1.  Initialize the biases using a
        Gaussian distribution with mean 0 and standard deviation 1.

        Note that the first layer is assumed to be an input layer, and
        by convention we won't set any biases for those neurons, since
        biases are only ever used in computing the outputs from later
        layers.

        This weight and bias initializer uses the same approach as in
        Chapter 1, and is included for purposes of comparison.  It
        will usually be better to use the default weight initializer
        instead.

        """
        self.biases = [np.random.randn(y, 1) for y in self.sizes[1:]]
        self.weights = [np.random.randn(y, x)
                        for x, y in zip(self.sizes[:-1], self.sizes[1:])]

    def feedforward(self, a):
        """Return the output of the network if ``a`` is input."""
        for b, w in zip(self.biases, self.weights):
            a = sigmoid(np.dot(w, a)+b)
        return a

    def SGD(self, training_data, epochs, mini_batch_size, eta,
            lmbda = 0.0,
            evaluation_data=None,
            monitor_evaluation_cost=False,
            monitor_evaluation_accuracy=False,
            monitor_training_cost=False,
            monitor_training_accuracy=False):
        """Train the neural network using mini-batch stochastic gradient
        descent.  The ``training_data`` is a list of tuples ``(x, y)``
        representing the training inputs and the desired outputs.  The
        other non-optional parameters are self-explanatory, as is the
        regularization parameter ``lmbda``.  The method also accepts
        ``evaluation_data``, usually either the validation or test
        data.  We can monitor the cost and accuracy on either the
        evaluation data or the training data, by setting the
        appropriate flags.  The method returns a tuple containing four
        lists: the (per-epoch) costs on the evaluation data, the
        accuracies on the evaluation data, the costs on the training
        data, and the accuracies on the training data.  All values are
        evaluated at the end of each training epoch.  So, for example,
        if we train for 30 epochs, then the first element of the tuple
        will be a 30-element list containing the cost on the
        evaluation data at the end of each epoch. Note that the lists
        are empty if the corresponding flag is not set.

        """
        if evaluation_data: n_data = len(evaluation_data)
        n = len(training_data)
        evaluation_cost, evaluation_accuracy = [], []
        training_cost, training_accuracy = [], []
        for j in xrange(epochs):
            random.shuffle(training_data)
            mini_batches = [
                training_data[k:k+mini_batch_size]
                for k in xrange(0, n, mini_batch_size)]
            for mini_batch in mini_batches:
                self.update_mini_batch(
                    mini_batch, eta, lmbda, len(training_data))
            print "Epoch %s training complete" % j
            if monitor_training_cost:
                cost = self.total_cost(training_data, lmbda)
                training_cost.append(cost)
                print "Cost on training data: {}".format(cost)
            if monitor_training_accuracy:
                accuracy = self.accuracy(training_data, convert=True)
                training_accuracy.append(accuracy)
                print "Accuracy on training data: {} / {}".format(
                    accuracy, n)
            if monitor_evaluation_cost:
                cost = self.total_cost(evaluation_data, lmbda, convert=True)
                evaluation_cost.append(cost)
                print "Cost on evaluation data: {}".format(cost)
            if monitor_evaluation_accuracy:
                accuracy = self.accuracy(evaluation_data)
                evaluation_accuracy.append(accuracy)
                print "Accuracy on evaluation data: {} / {}".format(
                    self.accuracy(evaluation_data), n_data)
            print
        return evaluation_cost, evaluation_accuracy, \
            training_cost, training_accuracy

    def update_mini_batch(self, mini_batch, eta, lmbda, n):
        """Update the network's weights and biases by applying gradient
        descent using backpropagation to a single mini batch.  The
        ``mini_batch`` is a list of tuples ``(x, y)``, ``eta`` is the
        learning rate, ``lmbda`` is the regularization parameter, and
        ``n`` is the total size of the training data set.

        """
        nabla_b = [np.zeros(b.shape) for b in self.biases]
        nabla_w = [np.zeros(w.shape) for w in self.weights]
        for x, y in mini_batch:
            delta_nabla_b, delta_nabla_w = self.backprop(x, y)
            nabla_b = [nb+dnb for nb, dnb in zip(nabla_b, delta_nabla_b)]
            nabla_w = [nw+dnw for nw, dnw in zip(nabla_w, delta_nabla_w)]
        self.weights = [(1-eta*(lmbda/n))*w-(eta/len(mini_batch))*nw
                        for w, nw in zip(self.weights, nabla_w)]
        self.biases = [b-(eta/len(mini_batch))*nb
                       for b, nb in zip(self.biases, nabla_b)]

    def backprop(self, x, y):
        """Return a tuple ``(nabla_b, nabla_w)`` representing the
        gradient for the cost function C_x.  ``nabla_b`` and
        ``nabla_w`` are layer-by-layer lists of numpy arrays, similar
        to ``self.biases`` and ``self.weights``."""
        nabla_b = [np.zeros(b.shape) for b in self.biases]
        nabla_w = [np.zeros(w.shape) for w in self.weights]
        # feedforward
        activation = x
        activations = [x] # list to store all the activations, layer by layer
        zs = [] # list to store all the z vectors, layer by layer
        for b, w in zip(self.biases, self.weights):
            z = np.dot(w, activation)+b
            zs.append(z)
            activation = sigmoid(z)
            activations.append(activation)
        # backward pass
        delta = (self.cost).delta(zs[-1], activations[-1], y)
        nabla_b[-1] = delta
        nabla_w[-1] = np.dot(delta, activations[-2].transpose())
        # Note that the variable l in the loop below is used a little
        # differently to the notation in Chapter 2 of the book.  Here,
        # l = 1 means the last layer of neurons, l = 2 is the
        # second-last layer, and so on.  It's a renumbering of the
        # scheme in the book, used here to take advantage of the fact
        # that Python can use negative indices in lists.
        for l in xrange(2, self.num_layers):
            z = zs[-l]
            sp = sigmoid_prime(z)
            delta = np.dot(self.weights[-l+1].transpose(), delta) * sp
            nabla_b[-l] = delta
            nabla_w[-l] = np.dot(delta, activations[-l-1].transpose())
        return (nabla_b, nabla_w)

    def accuracy(self, data, convert=False):
        """Return the number of inputs in ``data`` for which the neural
        network outputs the correct result. The neural network's
        output is assumed to be the index of whichever neuron in the
        final layer has the highest activation.

        The flag ``convert`` should be set to False if the data set is
        validation or test data (the usual case), and to True if the
        data set is the training data. The need for this flag arises
        due to differences in the way the results ``y`` are
        represented in the different data sets.  In particular, it
        flags whether we need to convert between the different
        representations.  It may seem strange to use different
        representations for the different data sets.  Why not use the
        same representation for all three data sets?  It's done for
        efficiency reasons -- the program usually evaluates the cost
        on the training data and the accuracy on other data sets.
        These are different types of computations, and using different
        representations speeds things up.  More details on the
        representations can be found in
        mnist_loader.load_data_wrapper.

        """
        if convert:
            results = [(np.argmax(self.feedforward(x)), np.argmax(y))
                       for (x, y) in data]
        else:
            results = [(np.argmax(self.feedforward(x)), y)
                        for (x, y) in data]
        return sum(int(x == y) for (x, y) in results)

    def total_cost(self, data, lmbda, convert=False):
        """Return the total cost for the data set ``data``.  The flag
        ``convert`` should be set to False if the data set is the
        training data (the usual case), and to True if the data set is
        the validation or test data.  See comments on the similar (but
        reversed) convention for the ``accuracy`` method, above.
        """
        cost = 0.0
        for x, y in data:
            a = self.feedforward(x)
            if convert: y = vectorized_result(y)
            cost += self.cost.fn(a, y)/len(data)
        cost += 0.5*(lmbda/len(data))*sum(
            np.linalg.norm(w)**2 for w in self.weights)
        return cost

    def save(self, filename):
        """Save the neural network to the file ``filename``."""
        data = {"sizes": self.sizes,
                "weights": [w.tolist() for w in self.weights],
                "biases": [b.tolist() for b in self.biases],
                "cost": str(self.cost.__name__)}
        f = open(filename, "w")
        json.dump(data, f)
        f.close()

#### Loading a Network
def load(filename):
    """Load a neural network from the file ``filename``.  Returns an
    instance of Network.

    """
    f = open(filename, "r")
    data = json.load(f)
    f.close()
    cost = getattr(sys.modules[__name__], data["cost"])
    net = Network(data["sizes"], cost=cost)
    net.weights = [np.array(w) for w in data["weights"]]
    net.biases = [np.array(b) for b in data["biases"]]
    return net

#### Miscellaneous functions
def vectorized_result(j):
    """Return a 10-dimensional unit vector with a 1.0 in the j'th position
    and zeroes elsewhere.  This is used to convert a digit (0...9)
    into a corresponding desired output from the neural network.

    """
    e = np.zeros((10, 1))
    e[j] = 1.0
    return e

def sigmoid(z):
    """The sigmoid function."""
    return 1.0/(1.0+np.exp(-z))

def sigmoid_prime(z):
    """Derivative of the sigmoid function."""
    return sigmoid(z)*(1-sigmoid(z))

有个更加有趣的变动就是在代码中增加了 L2 规范化。尽管这是一个主要的概念上的变动,在实现中其实相当简单。对大部分情况,仅仅需要传递参数 lmbda 到不同的方法中,主要是 Network.SGD 方法。实际上的工作就是一行代码的事在 Network.update_mini_batch 的倒数第四行。这就是我们改动梯度下降规则来进行权重下降的地方。尽管改动很小,但其对结果影响却很大!

其实这种情况在神经网络中实现一些新技术的常见现象。我们花费了近千字的篇幅来讨论规范化。概念的理解非常微妙困难。但是添加到程序中的时候却如此简单。精妙复杂的技术可以通过微小的代码改动就可以实现了。

另一个微小却重要的改动是随机梯度下降方法的几个标志位的增加。这些标志位让我们可以对在代价和准确度的监控变得可能。这些标志位默认是 False 的,但是在我们例子中,已经被置为 True 来监控 Network 的性能。另外,network2.py 中的 Network.SGD 方法返回了一个四元组来表示监控的结果。我们可以这样使用:

>>> evaluation_cost, evaluation_accuracy, 
... training_cost, training_accuracy = net.SGD(training_data, 30, 10, 0.5,
... lmbda = 5.0,
... evaluation_data=validation_data,
... monitor_evaluation_accuracy=True,
... monitor_evaluation_cost=True,
... monitor_training_accuracy=True,
... monitor_training_cost=True)

所以,比如 evaluation_cost 将会是一个 $$30$$ 个元素的列表其中包含了每个回合在验证集合上的代价函数值。这种类型的信息在理解网络行为的过程中特别有用。比如,它可以用来画出展示网络随时间学习的状态。其实,这也是我在前面的章节中展示性能的方式。然而要注意的是如果任何标志位都没有设置的话,对应的元组中的元素就是空列表。

另一个增加项就是在 Network.save 方法中的代码,用来将 Network 对象保存在磁盘上,还有一个载回内存的函数。这两个方法都是使用 JSON 进行的,而非 Python 的 pickle 或者 cPickle 模块——这些通常是 Python 中常见的保存和装载对象的方法。使用 JSON 的原因是,假设在未来某天,我们想改变 Network 类来允许非 sigmoid 的神经元。对这个改变的实现,我们最可能是改变在 Network.__init__ 方法中定义的属性。如果我们简单地 pickle 对象,会导致 load 函数出错。使用 JSON 进行序列化可以显式地让老的 Network 仍然能够 load

其他也还有一些微小的变动。但是那些只是 network.py 的微调。结果就是把程序从 $$74$$ 行增长到了 $$152$$ 行。

问题

  • 更改上面的代码来实现 L1 规范化,使用 L1 规范化使用 $$30$$ 个隐藏元的神经网络对 MNIST 数字进行分类。你能够找到一个规范化参数使得比无规范化效果更好么?
  • 看看 network.py 中的 Network.cost_derivative 方法。这个方法是为二次代价函数写的。怎样修改可以用于交叉熵代价函数上?你能不能想到可能在交叉熵函数上遇到的问题?在 network2.py 中,我们已经去掉了 Network.cost_derivative 方法,将其集成进了 CrossEntropyCost.delta 方法中。请问,这样是如何解决你已经发现的问题的?

发布了347 篇原创文章 · 获赞 607 · 访问量 260万+
展开阅读全文

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: 技术黑板 设计师: CSDN官方博客

分享到微信朋友圈

×

扫一扫,手机浏览