创建了神经网络后,我们需要进行权重和偏差的初始化。到现在,我们一直是根据在第一章中介绍的那样进行初始化。提醒你一下,之前的方式就是根据独立的均值为
结果表明,我们可以比使用正规化的高斯分布效果更好。为什么?假设我们使用一个很多的输入神经元,比如说
我们为了简化,假设,我们使用训练样本 x 其中一半的神经元值为
尤其是,我们可以从这幅图中看出
我已经研究了第一隐藏层的权重输入。当然,类似的论断也对后面的隐藏层有效:如果权重也是用正规化的高斯分布进行初始化,那么激活值将会接近
还有可以帮助我们进行更好地初始化么,能够避免这种类型的饱和,最终避免学习速度的下降?假设我们有一个有
这样的一个神经元更不可能饱和,因此也不大可能遇到学习速度下降的问题。
- 验证
$$z=\sum_j w_j x_j + b$$ 标准差为$$\sqrt{3/2}$$ 。下面两点可能会有帮助:(a) 独立随机变量的和的方差是每个独立随即便方差的和;(b)方差是标准差的平方。
我在上面提到,我们使用同样的方式对偏差进行初始化,就是使用均值为
让我们在 MNIST 数字分类任务上比较一下新旧两种权重初始化方式。同样,还是使用
>>> 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%。看起来的情况就是我们新的关于权重初始化的方式将训练带到了一个新的境界,让我们能够更加快速地得到好的结果。同样的情况在
在这个情况下,两个曲线并没有重合。然而,我做的实验发现了其实就在一些额外的回合后(这里没有展示)准确度其实也是几乎相同的。所以,基于这些实验,看起来提升的权重初始化仅仅会加快训练,不会改变网络的性能。然而,在第四张,我们会看到一些例子里面使用
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
,那你可能会需要重读前面关于这段代码的讨论。仅仅
和 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
方法,使用了我们新式改进后的初始化权重方法。如我们已经看到的,使用了均值为
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 函数实现了它。为什么这样做呢?答案就是代价函数在我们的网络中扮演了两种不同的角色。明显的角色就是代价是输出激活值 CrossEntropyCost.fn
方法来扮演。(注意,np.nan_to_num
调用确保了 Numpy 正确处理接近
所以,我们定义了第二个方法,CrossEntropyCost.delta
,目的就是让网络知道如何进行输出误差的计算。然后我们将这两个组合在一个包含所有需要知道的有关代价函数信息的类中。
类似地,network2.py
还包含了一个表示二次代价函数的类。这个是用来和第一章的结果进行对比的,因为后面我们几乎都在使用交叉函数。代码如下。QuadraticCost.fn
方法是关于网络输出 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.py
和 network.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
将会是一个
另一个增加项就是在 Network.save
方法中的代码,用来将 Network
对象保存在磁盘上,还有一个载回内存的函数。这两个方法都是使用 JSON 进行的,而非 Python 的 pickle
或者 cPickle
模块——这些通常是 Python 中常见的保存和装载对象的方法。使用 JSON 的原因是,假设在未来某天,我们想改变 Network
类来允许非 sigmoid 的神经元。对这个改变的实现,我们最可能是改变在 Network.__init__
方法中定义的属性。如果我们简单地 pickle 对象,会导致 load
函数出错。使用 JSON 进行序列化可以显式地让老的 Network 仍然能够 load
。
其他也还有一些微小的变动。但是那些只是 network.py
的微调。结果就是把程序从
- 更改上面的代码来实现 L1 规范化,使用 L1 规范化使用
$$30$$ 个隐藏元的神经网络对 MNIST 数字进行分类。你能够找到一个规范化参数使得比无规范化效果更好么? - 看看
network.py
中的Network.cost_derivative
方法。这个方法是为二次代价函数写的。怎样修改可以用于交叉熵代价函数上?你能不能想到可能在交叉熵函数上遇到的问题?在network2.py
中,我们已经去掉了Network.cost_derivative
方法,将其集成进了CrossEntropyCost.delta
方法中。请问,这样是如何解决你已经发现的问题的?
直到现在,我们还没有解释对诸如学习率
>>> import mnist_loader
>>> training_data, validation_data, test_data = \
... mnist_loader.load_data_wrapper()
>>> import network2
>>> net = network2.Network([784, 30, 10])
>>> net.SGD(training_data, 30, 10, 10.0, lmbda = 1000.0,
... evaluation_data=validation_data, monitor_evaluation_accuracy=True)
Epoch 0 training complete
Accuracy on evaluation data: 1030 / 10000
Epoch 1 training complete
Accuracy on evaluation data: 990 / 10000
Epoch 2 training complete
Accuracy on evaluation data: 1009 / 10000
...
Epoch 27 training complete
Accuracy on evaluation data: 1009 / 10000
Epoch 28 training complete
Accuracy on evaluation data: 983 / 10000
Epoch 29 training complete
Accuracy on evaluation data: 967 / 10000
我们分类准确度并不比随机选择更好。网络就像随机噪声产生器一样。
你可能会说,“这好办,降低学习率和规范化参数就好了。”不幸的是,你并不先验地知道这些就是需要调整的超参数。可能真正的问题出在
本节,我会给出一些用于设定超参数的启发式想法。目的是帮你发展出一套工作流来确保很好地设置超参数。当然,我不会覆盖超参数优化的每个方法。那是太繁重的问题,而且也不会是一个能够完全解决的问题,也不存在一种通用的关于正确策略的共同认知。总是会有一些新的技巧可以帮助你提高一点性能。但是本节的启发式想法能帮你开个好头。
宽的策略:在使用神经网络来解决新的问题时,一个挑战就是获得任何一种非寻常的学习,也就是说,达到比随机的情况更好的结果。这个实际上会很困难,尤其是遇到一种新类型的问题时。让我们看看有哪些策略可以在面临这类困难时候尝试。
假设,我们第一次遇到 MNIST 分类问题。刚开始,你很有激情,但是当第一个神经网络完全失效时,你会就得有些沮丧。此时就可以将问题简化。丢开训练和验证集合中的那些除了
你通过简化网络来加速实验进行更有意义的学习。如果你相信
你可以通过提高监控的频率来在试验中获得另一个加速了。在 network2.py
中,我们在每个训练的回合的最后进行监控。每回合 network2.py
并没有做这样的监控。但是作为一个凑合的能够获得类似效果的方案,我们将训练数据减少到前
>>> net = network2.Network([784, 10])
>>> net.SGD(training_data[:1000], 30, 10, 10.0, lmbda = 1000.0, \
... evaluation_data=validation_data[:100], \
... monitor_evaluation_accuracy=True)
Epoch 0 training complete
Accuracy on evaluation data: 10 / 100
Epoch 1 training complete
Accuracy on evaluation data: 10 / 100
Epoch 2 training complete
Accuracy on evaluation data: 10 / 100
...
我们仍然获得完全的噪声!但是有一个进步:现在我们每一秒钟可以得到反馈,而不是之前每 10 秒钟才可以。这意味着你可以更加快速地实验其他的超参数,或者甚至近同步地进行不同参数的组合的评比。
在上面的例子中,我设置
>>> net = network2.Network([784, 10])
>>> net.SGD(training_data[:1000], 30, 10, 10.0, lmbda = 20.0, \
... evaluation_data=validation_data[:100], \
... monitor_evaluation_accuracy=True)
Epoch 0 training complete
Accuracy on evaluation data: 12 / 100
Epoch 1 training complete
Accuracy on evaluation data: 14 / 100
Epoch 2 training complete
Accuracy on evaluation data: 25 / 100
Epoch 3 training complete
Accuracy on evaluation data: 18 / 100
...
哦也!现在有了信号了。不是非常糟糕的信号,却真是一个信号。我们可以基于这点,来改变超参数从而获得更多的提升。可能我们猜测学习率需要增加(你可以能会发现,这只是一个不大好的猜测,原因后面会讲,但是相信我)所以为了测试我们的猜测就将
>>> net = network2.Network([784, 10])
>>> net.SGD(training_data[:1000], 30, 10, 100.0, lmbda = 20.0, \
... evaluation_data=validation_data[:100], \
... monitor_evaluation_accuracy=True)
Epoch 0 training complete
Accuracy on evaluation data: 10 / 100
Epoch 1 training complete
Accuracy on evaluation data: 10 / 100
Epoch 2 training complete
Accuracy on evaluation data: 10 / 100
Epoch 3 training complete
Accuracy on evaluation data: 10 / 100
...
这并不好!告诉我们之前的猜测是错误的,问题并不是学习率太低了。所以,我们试着将
>>> net = network2.Network([784, 10])
>>> net.SGD(training_data[:1000], 30, 10, 1.0, lmbda = 20.0, \
... evaluation_data=validation_data[:100], \
... monitor_evaluation_accuracy=True)
Epoch 0 training complete
Accuracy on evaluation data: 62 / 100
Epoch 1 training complete
Accuracy on evaluation data: 42 / 100
Epoch 2 training complete
Accuracy on evaluation data: 43 / 100
Epoch 3 training complete
Accuracy on evaluation data: 61 / 100
...
这样好点了!所以我们可以继续,逐个调整每个超参数,慢慢提升性能。一旦我们找到一种提升性能的
所有这些作为一种宽泛的策略看起来很有前途。然而,我想要回到寻找超参数的原点。实际上,即使是上面的讨论也传达出过于乐观的观点。实际上,很容易会遇到神经网络学习不到任何知识的情况。你可能要花费若干天在调整参数上,仍然没有进展。所以我想要再重申一下在前期你应该从实验中尽可能早的获得快速反馈。直觉上看,这看起来简化问题和架构仅仅会降低你的效率。实际上,这样能够将进度加快,因为你能够更快地找到传达出有意义的信号的网络。一旦你获得这些信号,你可以尝尝通过微调超参数获得快速的性能提升。这和人生中很多情况一样——万事开头难。
好了,上面就是宽泛的策略。现在我们看看一些具体的设置超参数的推荐。我会聚焦在学习率
学习率:假设我们运行了三个不同学习率($$\eta=0.025$$、$$\eta=0.25$$、$$\eta=2.5$$)的 MNIST 网络。我们会像前面介绍的实验那样设置其他的超参数,进行
使用
然而,如果
所以,有了这样的想法,我们可以如下设置
显然,$$\eta$$ 实际值不应该比阈值大。实际上,如果
在 MNIST 数据中,使用这样的策略会给出一个关于学习率
这看起来相当直接。然而,使用训练代价函数来选择
使用 Early stopping 来确定训练的回合数:正如我们在本章前面讨论的那样,Early stopping 表示在每个回合的最后,我们都要计算验证集上的分类准确度。当准确度不再提升,就终止它。这让选择回合数变得很简单。特别地,也意味着我们不再需要担心显式地掌握回合数和其他超参数的关联。而且,这个过程还是自动的。另外,Early stopping 也能够帮助我们避免过匹配。尽管在实验前期不采用 Early stopping,这样可以看到任何过匹配的信号,使用这些来选择规范化方法,但 early stopping 仍然是一件很棒的事。
我们需要再明确一下什么叫做分类准确度不再提升,这样方可实现 Early stopping。正如我们已经看到的,分类准确度在整体趋势下降的时候仍旧会抖动或者震荡。如果我们在准确度刚开始下降的时候就停止,那么肯定会错过更好的选择。一种不错的解决方案是如果分类准确度在一段时间内不再提升的时候终止。例如,我们要解决 MNIST 问题。如果分类准确度在近
这种
我们还没有使用提前终止在我们的 MNIST 实验中。原因是我们已经比较了不同的学习观点。这样的比较其实比较适合使用同样的训练回合。但是,在 network2.py
中实现提前终止还是很有价值的:
- 修改
network2.py
来实现提前终止,并让$$n$$ 回合不提升终止策略中的$$n$$ 称为可以设置的参数。 - 你能够想出不同于
$$n$$ 回合不提升终止策略的其他提前终止策略么?理想中,规则应该能够获得更高的验证准确度而不需要训练太久。将你的想法实现在network2.py
中,运行这些实验和$$10$$ 回合不提升终止策略比较对应的验证准确度和训练的回合数。
学习率调整:我们一直都将学习率设置为常量。但是,通常采用可变的学习率更加有效。在学习的前期,权重可能非常糟糕。所以最好是使用一个较大的学习率让权重变化得更快。越往后,我们可以降低学习率,这样可以作出更加精良的调整。
我们要如何设置学习率呢?其实有很多方法。一种自然的观点是使用提前终止的想法。就是保持学习率为一个常量知道验证准确度开始变差。然后按照某个量下降学习率,比如说按照
可变学习率可以提升性能,但是也会产生大量可能的选择。这些选择会让人头疼——你可能需要花费很多精力才能优化学习规则。对刚开始实验,我建议使用单一的常量作为学习率的选择。这会给你一个比较好的近似。后面,如果你想获得更好的性能,值得按照某种规则进行实验,根据我已经给出的资料。
A readable recent paper which demonstrates the benefits of variable learning rates in attacking MNIST isDeep, Big, Simple Neural Nets Excel on Handwritten Digit Recognition, by Dan Claudiu Cireșan, Ueli Meier, Luca Maria Gambardella, and Jürgen Schmidhuber (2010).
- 更改
network2.py
实现学习规则:每次验证准确度满足满足$$10$$ 回合不提升终止策略时改变学习率;当学习率降到初始值的$$1/128$$ 时终止。
规范化参数:我建议,开始时不包含规范化($$\lambda=0.0$$),确定
- 使用梯度下降来尝试学习好的超参数的值其实很受期待。你可以想像关于使用梯度下降来确定
$$\lambda$$ 的障碍么?你能够想象关于使用梯度下降来确定$$\eta$$ 的障碍么?
在本书前面,我是如何选择超参数的:如果你使用本节给出的推荐策略,你会发现你自己找到的
相较于这样的折衷,其实我本可以尝试优化每个单一的观点的超参数选择。理论上,这可能是更好更公平的方式,因为那样的话我们可以看到每个观点的最优性能。但是,我们现在依照目前的规范进行了众多的比较,实践上,我觉得要做到需要过多的计算资源了。这也是我使用折衷方式来采用尽可能好(却不一定最优)的超参数选择。
minibatch 大小:我们应该如何设置 minibatch 的大小?为了回答这个问题,让我们先假设正在进行在线学习,也就是说使用大小为
一个关于在线学习的担忧是使用只有一个样本的 minibatch 会带来关于梯度的错误估计。实际上,误差并不会真的产生这个问题。原因在于单一的梯度估计不需要绝对精确。我们需要的是确保代价函数保持下降的足够精确的估计。就像你现在要去北极点,但是只有一个不大精确的(差个
基于这个观点,这看起来好像我们需要使用在线学习。实际上,情况会变得更加复杂。在上一章的问题中 我指出我们可以使用矩阵技术来对所有在 minibatch 中的样本同时计算梯度更新,而不是进行循环。所以,取决于硬件和线性代数库的实现细节,这会比循环方式进行梯度更新快好多。也许是
现在,看起来这对我们帮助不大。我们使用
这里是对 minibatch 中所有训练样本求和。而在线学习是
即使它仅仅是
这看起来项做了
所以,选择最好的 minibatch 大小也是一种折衷。太小了,你不会用上很好的矩阵库的快速计算。太大,你是不能够足够频繁地更新权重的。你所需要的是选择一个折衷的值,可以最大化学习的速度。幸运的是,minibatch 大小的选择其实是相对独立的一个超参数(网络整体架构外的参数),所以你不需要优化那些参数来寻找好的 minibatch 大小。因此,可以选择的方式就是使用某些可以接受的值(不需要是最优的)作为其他参数的选择,然后进行不同 minibatch 大小的尝试,像上面那样调整
当然,你也发现了,我这里并没有做到这么多。实际上,我们的实现并没有使用到 minibatch 更新快速方法。就是简单使用了 minibatch 大小为
自动技术:我已经给出很多在手动进行超参数优化时的启发式规则。手动选择当然是种理解网络行为的方法。不过,现实是,很多工作已经使用自动化过程进行。通常的技术就是网格搜索(grid search),可以系统化地对超参数的参数空间的网格进行搜索。网格搜索的成就和限制(易于实现的变体)在 James Bergstra 和 Yoshua Bengio
总结:跟随上面的经验并不能帮助你的网络给出绝对最优的结果。但是很可能给你一个好的开始和一个改进的基础。特别地,我已经非常独立地讨论了超参数的选择。实践中,超参数之间存在着很多关系。你可能使用
选择超参数的难度由于如何选择超参数的方法太繁多(分布在太多的研究论文,软件程序和仅仅在一些研究人员的大脑中)变得更加困难。很多很多的论文给出了(有时候矛盾的)建议。然而,还有一些特别有用的论文对这些繁杂的技术进行了梳理和总结。Yoshua Bengio 在
在你读这些文章时,特别是进行试验时,会更加清楚的是超参数优化就不是一个已经被完全解决的问题。总有一些技巧能够尝试着来提升性能。有句关于作家的谚语是:“书从来不会完结,只会被丢弃。”这点在神经网络优化上也是一样的:超参数的空间太大了,所以人们无法真的完成优化,只能将问题丢给后人。所以你的目标应是发展出一个工作流来确保自己快速地进行参数优化,这样可以留有足够的灵活性空间来尝试对重要的参数进行更加细节的优化。
设定超参数的挑战让一些人抱怨神经网络相比较其他的机器学习算法需要大量的工作进行参数选择。我也听到很多不同的版本:“的确,参数完美的神经网络可能会在这问题上获得最优的性能。但是,我可以尝试一下随机森林(或者 SVM 或者……这里脑补自己偏爱的技术)也能够工作的。我没有时间搞清楚那个最好的神经网络。” 当然,从一个实践者角度,肯定是应用更加容易的技术。这在你刚开始处理某个问题时尤其如此,因为那时候,你都不确定一个机器学习算法能够解决那个问题。但是,如果获得最优的性能是最重要的目标的话,你就可能需要尝试更加复杂精妙的知识的方法了。如果机器学习总是简单的话那是太好不过了,但也没有一个应当的理由说机器学习非得这么简单。
本章中讲述的每个技术都是很值得学习的,但是不仅仅是由于那些我提到的愿意。更重要的其实是让你自己熟悉在神经网络中出现的问题以及解决这些问题所进行分析的方式。所以,我们现在已经学习了如何思考神经网络。本章后面部分,我会简要地介绍一系列其他技术。这些介绍相比之前会更加粗浅,不过也会传达出关于神经网络中多样化的技术的精神。
通过反向传播进行的随机梯度下降已经在 MNIST 数字分类问题上有了很好的表现。然而,还有很多其他的观点来优化代价函数,有时候,这些方法能够带来比 minibatch 随机梯度下降更好的效果。本节,我会介绍两种观点,Hessian 和 momentum 技术。
Hessian 技术:为了更好地讨论这个技术,我们先把神经网络放在一边。相反,我直接思考最小化代价函数
我们可以将其压缩为:
其中
使用微积分,我们可证明右式表达式可以进行最小化,选择:
根据(105)是代价函数的比较好的近似表达式,我们期望从点
- 选择开始点,$$w$$
- 更新
$$w$$ 到新点$$w' = w - H_{-1}\nabla C$$ ,其中 Hessian$$H$$ 和$$\nabla C$$ 在$$w$$ 处计算出来的 - 更新
$$w'$$ 到新点$$w'' = w' - H'^{-1}\nabla' C$$ ,其中 Hessian$$H'$$ 和$$\nabla' C$$ 在$$w'$$ 处计算出来的 - ...
实际应用中,(105)是唯一的近似,并且选择更小的步长会更好。我们通过重复地使用改变量
这个最小化代价函数的方法常常被称为 Hessian 技术 或者 Hessian 优化。在理论上和实践中的结果都表明 Hessian 方法比标准的梯度下降方法收敛速度更快。特别地,通过引入代价函数的二阶变化信息,可以让 Hessian 方法避免在梯度下降中常碰到的多路径(pathologies)问题。而且,反向传播算法的有些版本也可以用于计算 Hessian。
如果 Hessian 优化这么厉害,为何我们这里不使用它呢?不幸的是,尽管 Hessian 优化有很多可取的特性,它其实还有一个不好的地方:在实践中很难应用。这个问题的部分原因在于 Hessian 矩阵的太大了。假设你有一个
基于 momentum 的梯度下降:直觉上看,Hessian 优化的优点是它不仅仅考虑了梯度,而且还包含梯度如何变化的信息。基于 momentum 的梯度下降就基于这个直觉,但是避免了二阶导数的矩阵的出现。为了理解 momentum 技术,想想我们关于梯度下降的原始图片,其中我们研究了一个球滚向山谷的场景。那时候,我们发现梯度下降,除了这个名字外,就类似于球滚向山谷的底部。momentum 技术修改了梯度下降的两处使之类似于这个物理场景。首先,为我们想要优化的参数引入了一个称为速度(velocity)的概念。梯度的作用就是改变速度,而不是直接的改变位置,就如同物理学中的力改变速度,只会间接地影响位置。第二,momentum 方法引入了一种摩擦力的项,用来逐步地减少速度。
让我们给出更加准确的数学描述。我们引入对每个权重
在这些方程中,$$\mu$$ 是用来控制阻碍或者摩擦力的量的超参数。为了理解这个公式,可以考虑一下当
每一步速度都不断增大,所以我们会越来越快地达到谷底。这样就能够确保 momentum 技术比标准的梯度下降运行得更快。当然,这里也会有问题,一旦达到谷底,我们就会跨越过去。或者,如果梯度本该快速改变而没有改变,那么我们会发现自己在错误的方向上移动太多了。这就是在(107)式中使用
我到现在也没有把
关于 momentum 技术的一个很好的特点是它基本上不需要改变太多梯度下降的代码就可以实现。我们可以继续使用反向传播来计算梯度,就和前面那样,使用随机选择的 minibatch 的方法。这样的话,我们还是能够从 Hessian 技术中学到的优点的——使用梯度如何改变的信息。也仅仅需要进行微小的调整。实践中,momentum 技术很常见,也能够带来学习速度的提升。
- 如果我们使用
$$\mu>1$$ 会有什么问题? - 如果我们使用
$$\mu<0$$ 会有什么问题?
- 增加基于 momentum 的随机梯度下降到
network2.py
中。
其他优化代价函数的方法:很多其他的优化代价函数的方法也被提出来了,并没有关于哪种最好的统一意见。当你越来越深入了解神经网络时,值得去尝试其他的优化技术,理解他们工作的原理,优势劣势,以及在实践中如何应用。前面我提到的一篇论文,介绍并对比了这些技术,包含共轭梯度下降和 BFGS 方法(也可以看看 limited memory BFGS,L-BFGS)。另一种近期效果很不错技术是 Nesterov 的加速梯度技术,这个技术对 momentum 技术进行了改进。然而,对很多问题,标准的随机梯度下降算法,特别当 momentum 用起来后就可以工作得很好了,所以我们会继续在本书后面使用随机梯度下算法。
到现在,我们使用的神经元都是 sigmoid 神经元。理论上讲,从这样类型的神经元构建起来的神经网络可以计算任何函数。实践中,使用其他模型的神经元有时候会超过 sigmoid 网络。取决于不同的应用,基于其他类型的神经元的网络可能会学习得更快,更好地泛化到测试集上,或者可能两者都有。让我们给出一些其他的模型选择,便于了解常用的模型上的变化。
可能最简单的变种就是
这其实和 sigmoid 神经元关系相当密切。回想一下
进行简单的代数运算,我们可以得到
也就是说,$$\tanh$$ 仅仅是 sigmoid 函数的按比例变化版本。我们同样也能用图像看看
这两个函数之间的一个差异就是
类似于 sigmoid 神经元,基于
- 证明公式(111)
那么你应该在网络中使用什么类型的神经元呢,$$\tanh$$ 还是 sigmoid?实话讲,确实并没有先验的答案!然而,存在一些理论论点和实践证据表明
例如Efficient BackProp, by Yann LeCun, Léon Bottou, Genevieve Orr and Klaus-Robert Müller (1998), and Understanding the difficulty of training deep feedforward networks, by Xavier Glorot and Yoshua Bengio (2010).
让我简要介绍一下其中关于
我们应当如何看待这个论点?尽管论点是建设性的,但它还只是一个启发式的规则,而非严格证明说
另一个变体就是 Rectified Linear 神经元或者 Rectified Linear Unit,简记为 RLU。输入为
图像上看,函数
显然,这样的神经元和 sigmoid 和
什么时候应该使用 RLU 而非其他神经元呢?一些近期的图像识别上的研究工作找到了使用 RLU 所带来的好处。然而,就像
我已经给出了一些不确定性的描述,指出我们现在还没有一个坚实的理论来解释如何选择激活函数。实际上,这个问题比我已经讲过的还要困难,因为其实是有无穷多的可能的激活函数。所以对给定问题,什么激活函数最好?什么激活函数会导致学习最快?哪个能够给出最高的测试准确度?其实现在并没有太多真正深刻而系统的研究工作。理想中,我们会有一个理论告诉人们,准确细致地,如何选择我们的激活函数。另外,我们不应该让这种缺失阻碍我们学习和应用神经网络!我们已经有了一些强大的工作,可以使用它们完成很多的研究工作。本书剩下的部分中,我会继续使用 sigmoid 神经元作为首选,因为他们其实是强大的也给出了具体关于神经网络核心思想的示例。但是你需要记住的是,这些同样的想法也都可以用在其他类型的神经元上,有时候的确会有一些性能的提升。
问题:你怎么看那些全部由实验效果支撑(而非数学保证)的使用和研究机器学习技术呢?同样,在哪些场景中,你已经注意到这些技术失效了?
答案:你需要认识到,我们的理论工具的缺乏。有时候,我们有很好的关于某些特定的技术应该可行的数学直觉。有时候我们的直觉最终发现是错误的。…… 这个问题其实是:我的方法在这个特定的问题的工作得多好,还有方法表现好的那些问题的范围有多大。
- Question and answer by Yann LeCun
曾经我参加量子力学基础的会议时,我注意到让我最好奇的口头表达:在报告结束时,听众的问题通常是以“我对你的观点很赞同,但是...”开始。量子力学基础不是我的擅长领域,我注意到这种类型的质疑,因为在其他的科学会议上,我很少(或者说,从未)听到这种同情。那时候,我思考了这类问题存在的原因,实际上是因为这个领域中很少有重大的进展,人们都是停在原地。后来,我意识到,这个观念相当的尖刻。发言人正在尝试解决一些人们所遇到的一些最难的问题。进展当然会非常缓慢!但是,听听人们目前正在思考的方式也是非常有价值的,即使这些尝试不一定会有无可置疑的新进展。
你可能会注意到类似于“我对你的观点很赞同,但是...”的话语。为了解释我们已经看到的情况,我通常会使用“启发式地,...”或者“粗略地讲,...”,然后接上解释某个现象或者其他问题的故事。这些故事是可信的,但是实验性的证据常常是不够充分的。如果你通读研究文献,你会发现在神经网络研究中很多类似的表达,基本上都是没有太过充分的支撑证据的。所以我们应该怎样看待这样的故事呢?
在科学的很多分支——尤其是那些解决相当简单现象的领域——很容易会得到一些关于很一般的假说的非常扎实非常可靠的证据。但是在神经网络中,存在大量的参数和超参数及其间极其复杂的交互。在这样复杂系统中,构建出可靠的一般的论断就尤其困难。在完全一般性上理解神经网络实际上,和量子力学基础一样,都是对人类思维极限的挑战。实际上,我们通常是和一些一般的理论的具体的实例在打交道——找到正面或者反面的证据。所以,这些理论在有新的证据出现时,也需要进行调整甚至丢弃。
对这种情况的一种观点是——任何启发式的关于神经网络的论点会带来一个挑战。例如,考虑之前我引用的语句 ,解释 dropout 工作的原因:“这个技术减少了复杂的神经元之间的互适应,因为一个神经元不能够依赖于特定其他神经元的存在。因此,这个就强制性地让我们学习更加健壮的在很多不同的神经元的随机子集的交集中起到作用的那些特征。”这是一个丰富而又争议的假说,我们可以根据这个观点发展出一系列的研究项目,搞清楚哪些部分真的,哪些是假的,那个需要变化和改良。实际上,有一小部分研究人员正在调查 dropout(和其他变体)试着理解其工作的机制,还有 dropout 的极限所在。所以,这些研究也跟随着那些我们已经讨论过的启发式想法。每个启发式想法不仅仅是一个(潜在的)解释,同样也是一种更加细化地调查和理解的挑战。
当然,对某个单独的人去研究所有这些启发式想法其实在时间上是不允许的。需要神经网络的研究群体花费数十年(或者更多)来发展出一个相当强大,基于证据的关于神经网络工作的原理的理论。那么这是不是就意味着我们应当因为它的不严格和无法充分地证明而放弃启发式规则么?不!实际上,我们需要这样的启发式想法来启迪和指导我们的思考。这有点像大航海时代:早期的探险家在一种重要的指导方式都有错误的前提下有时候都进行了探索(并作出了新的发现)。后来,这些错误在我们对地理知识的清晰后而被纠正过来。当你对某件事理解不深时——就像探险家对地理的理解和我们现在对神经网络的理解——忽略一些相对严格的纠正每一步思考而胆大地探索若干问题显得更加重要。所以你应该将这些故事看成是一种关于我们如何思考神经网络的有用的指导,同时保留关于这些想法的能力极限的合理的关注,并细致地跟踪对任何一个推理的证据的强弱。换言之,我们需要很好的故事来不断地激励和启发自己去勇敢地探索,同时使用严格的深刻的调查来发现真理。