2- 深度学习之神经网络核心原理与算法-提高神经网络学习效率
上一章我们介绍了基本的前馈神经网络的实现。
本节我们来介绍一些可以提高神经网络学习效率的方法。
并行计算
加快神经网络训练最直接的方式。我们需要得到的是一个网络的拓扑结构和各个节点上权重值的矩阵。
以一个小规模的全连接网络为例:
mark假设有5层,每层10个节点,输入向量有1000维。最后一层是一个节点,那么这个网络中一共有多少个权值需要被训练出来。
- 第一层: 1000维 乘以 10个节点
- 第二层,三层,四层。 10 乘以 10
- 第五层: 10 乘以 1个
加起来: 10000+300+10 =10310个维度
学习率: 0.001 假设挪动: 2000次 总共需要挪动20620000次
正常的情况下这个挪动是串行的,下一次挪动必须等待上一次挪动完毕。挪动等待时间,求导。
我们测试练习用cpu是可以的。但是工业是不够的
mark成熟方案: 使用NVIDIA显卡 装上cuda 使用gpu进行并行计算。一块显卡拥有很多的cuda内核可以进行并行计算。
cuda
mark不同型号的显卡,cuda核心数也不同。型号越新cuda数越多。
训练的数据不是放在电脑的内存中,而是会被拷贝至gpu的显存中。显存越大,训练数据支持越大。
cuda支持复杂的并行计算,如果我们使用了TensorFlow之类的框架,就不需要关心底层的并行计算的cuda实现。我们不需要和cuda打交道。
一个工作站安装多块显卡。国内的一些合作企业。
梯度消失问题
梯度消失问题是在早期的bp网络中比较常见的一个问题。一旦发生了梯度消失的问题,我们的训练就很难继续下去。训练不再收敛,也就是loss不再下降,准确率过早的无法提高。
mark这是一个简单的神经元首尾相连组成一个神经网络。位于网络前部的w1在更新的时候,需要计算损失函数对w1的偏导。
我们根据链式法则可以得到w1的偏导的表达式
mark第一项。和第三项实际是在找sigmoid函数上的斜率。
mark marksigmoid函数在大于4或者小于负4时,它的导数,导数就是函数的切线斜率接近于0.
这两项式子只要任何一个处于大于4或者小于负4就会造成导数值接近于0.曲线的斜率接近水平了。
这个接近0的值连乘时会乘出一个非常小的数。当你的网络层数很多时,越往前传情况越糟糕。w的变化会越来越慢。导致这层的w没有学到什么东西。
这就是梯度消失,或者叫梯度弥散问题。
如何避免梯度消失问题。
我们刚才说导数小,导致每次更新的时候的值过于的小,是不是导数大每次更新的值就会大呢,网络的学习速度就会快呢?
如果你需要导数大,最好是:
mark在这个链式相乘的法则中每一项绝对值大于1.小于1,很小的数乘以很小的数会导致越乘越小。就会导致靠w的变化速度越来越慢,学习到的越来越低。
我们再来关系一下第二项和第三项。
mark第二项把z=wx+b带入。第三项其实就是对sigmoid函数求导(sigmoid函数的特性)
mark mark我们的想法是: 消除链式法则中发生连乘时每一项绝对值小于1的情况。
mark方法1
初始化一个合适的w,我们可以把w的值初始化的大一些。
把x=0带入方程:
mark mark mark看上去满足了我们的要求,链式相乘绝对值大于1.
梯度爆炸。原来是因为因为前面的w1变化太慢而导致梯度消失的问题发生。
现在是网络前端的这个变化率太高了。一次变化量很大,网络层数很多,有十层。十个2.5相乘就是9536
这样会造成你挪动的步子太大了,梯度爆炸。
因此我们给w初始化一个比较大的初始值,这个是不太可行的。
方法2
我们要使用Relu函数来解决我们的梯度消失和爆炸的问题。
mark mark解决这个问题的方法也就是使用导数值比较合适的激励函数来解决
在x和0中取最大值,这个函数在原点左侧部分斜率为0,右侧则是一条斜率为1的直线。
在第一象限导数恒为0.x在大于0时呈线性特点,小于0时则会是一条直线。
relu函数提供了非常良好的非线性特征。第一象限的这条直线表明它的导数是恒为1的。
x小于0这部分,导数恒为0.relu函数两个显而易见的优点:
-
在第一象限不会有明显的梯度消失问题。因为导数恒为1
-
我们在初始化w时,因为是随机化,w有的会很大,有的会很小。连乘时就不会出现很大一直连乘,或者很小一直连乘。
-
求导代价小。
归一化
几乎所有机器学习算法在开始训练之前都必须克服的问题:
mark假设我们要比较中国人和日本人的收入差距。从统计学角度。
都抽了一千人。 中国人平均年薪55000,日本人平均年薪2600000.如果这时候得出日本人年薪是中国人几十倍就很不符合现实了。以当地货币作为单位的。
因为人民币和日元的汇率。日本人是中国人的2.8倍。
计算机的系统中数字是没有量纲的,没有单位的,只有具体的浮点数或者定点数。
mark左边同学的卷子是5分制的,而右边这两个是100分的卷子
在机器学习的过程中,由于数字单位的影响,导致分布范围较广的值和分布范围较窄的值会在训练的过程中有着不同的影响力,那么结果是会引起结果对于某些值过于敏感,或者是对于某些值不那么敏感。这是我们不愿意看到的。
这时我们就会使用归一化的操作,把数据大小分布在一个比例协调的范围之内。
常见的归一化方法:
- 线性函数归一化(Min-Max Scaling)
- 0均值标准化(Z-Score Standardization)
两种归一化的目的都是让各个维度的数据拉伸到一个相近的维度范围
线性函数归一化公式:
mark假设x是一个向量,先用最大值与最小值的差作为分母。再用每个维度的数值与最小值作为分子,会得到一个比值。这个比值就是归一化之后的结果值。
每个数值经过这样的投射,会变成一个0-1之间的数值。这个数值表示自己在该维度样本中所处的位置比例。
mark做了归一化之后,数据就会呈现出上图这种效果。左边图为x和y两个维度在原始的分布情况。
中间这幅图是对于数据进行(0中心化)之后,也就是x和y的值都减去各自的平均值。可以得到一堆有正有负的值,且0在中心的位置。
最右边是归一化以后的图。数据的分布不再是一个狭长的形状而是一个趋于正方形的形状。
深度学习中也会遇到归一化的问题,最常见的就是使用一种叫做批归一化(Batch Noramlization)的过程。
在整个网络的任何一层都可以加入批归一化的操作。这就等于把每层网络看成是一个独立的分类模型。
这样就可以避免网络因为数据分布不同所带来的尴尬。
参数初始化问题
在搭建完一个神经网络之后,再开始正式训练神经网络之前,有一件事我们不得不做。
要对整个网络中所有的待定系数进行初始化操作。我们究竟应该把这些权值w赋值为多少合适?
怎样初始化权重
结论: 一种在业界比较认可的说法是把整个网络中所有的w初始化为以0为均值,以某个很小的值为标准差的正态分布的方式,通常效果会比较好。
具体初始化: 以0为均值,以1为方差的分布来随机初始化。
mark其实还有很多种其他的初始化方法,但是大多数都是高斯分布类似的,或者是变种的方式。
关于如何初始化这些w的值,在业界已经讨论了很久,最后的结果也属于仁者见仁智者见智
目前普遍得到认可的就是这种基于高斯分布的初始化方法。
我们可以这样理解这种初始化方法,就是在一个模型中对于输入的各个维度的权重的设置,就相当于一种重视的程度。有的维度对于模型的判断结果非常重要,属于正面因素。
有的维度对于模型的判断结果不那么重要,属于负面因素。
正面因素和负面因素都是比较少数的,而大部分的维度对于模型的判断结果是比较中庸的。这一部分中庸的维度就占了绝大多数。
有统计学基础或者是数据认知的朋友应该都会了解,自然界的大部分数据分布都呈现出高斯分布的特点。
我们看到任何一种事务都是中庸的比较多,极端的比较少。比如一个地区人群的收入,
收入低和高都比较少,收入中等的人群最多。一个地区成人的身高分布等。
同样一个城市人口寿命分布也是这样。
输入向量既然是没有经过什么特征提取的自然信息,这些特征中应该也会有其重要程度。
具有区分价值的维度是少数,大部分信息的特征不太明显。自然采纳度也不同。
怎样初始化权重 二
怎样在代码里添加参数的初始化方法。
在我们原本的init方法中添加一个初始化我们每层的偏置和权重的方法large_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:])]
再新增一个方法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:])]
mark
除以x的平方。
其他代码保持不变,同样经过30轮epoch,我们的准确率达到了96%
mark正则化
正则化是机器学习中一种常见的概念。正则化不仅在深度学习中有,在传统的机器学习中也有。
单纯从名字上不好理解。但是其实它的意义还是比较简单的。
泛化能力
机器学习中,通常通过大量样本放入模型中训练然后得到待定的系数。
而不论是哪种模型我们都希望这种模型在精确的前提下尽可能的简洁。这里我们所说的精确不止是在测试集上精确就够了。
泛化能力好: 模型在测试集以及其他验证集上也要表现的同样好。
日常: 对于观察到的各种认知对象来说,描述共性的东西越抽象,越简洁,其泛化性也就越好。
相反,越是精确描述个体的东西,通常“个性化”的特点就非常明显,越具体,越复杂,泛化性就越差。
泛化能力举例
我们描述一个物体是方的。通常指这是一个四边形,两两平行,并两两垂直。
mark忽略了颜色,大小等诸多特征。参数变多了,有了约束性,泛化性变低。
描述的更为具体,参数更多,泛化性就会是这三个词中最低的一个。
正则化的过程就是来为我们找到更为简洁的描述方式的量化过程。
L1正则化
对于损失函数的改造。
mark这就是改造完毕,带有正则化项的损失函数。我们之前接触到的损失函数只有C0这一部分。没有后面的:
mark前面的损失函数值C0,我们称之为经验风险.
mark后面的表达式加入了正则项的,叫做结构风险
mark结构风险就是我们刚刚提到的风险,我们希望这种描述能够简洁来保证泛化性的良好。
这个正则项含义是把整个模型中所有的权重w绝对值加起来之后除以样本的总数量n。
这里n上面的分子 拉姆达 不是我们在机器学习中所提到的学习率,而是一个权重,称之为正则化系数或惩罚系数。
表示对这部分有多重视,如果你很重视结构风险,很不希望结构风险太大。我们就加大 拉姆达 的值,迫使损失函数向着权值减小的方向快速移动。
换句话说就是w的值越大,整个因子的值就越大。也就是我们说的越不简洁。
这里我们说的正则化因子其实叫做L1正则化项。
L2正则化项
mark只不过将绝对值变成了w的平方,将n变成了2n
L1正则项的损失函数导数
mark我们求偏c/ 偏w和以前不一样了。
mark我们反向更新的时候,也和以前不一样了。
sgn函数表示取w的符号。大于0表示正1,小于0表示负1
mark整个导数,除了经验风险对w贡献的部分,还有后面结构风险对于w求导贡献的部分。
markL2正则项的导数
mark可视化正则化的实现过程。
假设在一个模型中只有两个维度,w1和w2作为待定的系数,最终的理想解在圆心或者说抛物线的最低点。
mark这里在第一象限只是我们画出来在第一象限。由于w1和w2在初始化时可能在空白处的
任何地方,那么在训练的过程中,w1和w2就会逐步从从初始化的位置,向圆心靠拢。
圆心就是我们的最优解,在训练过程中w1和w2会从上下左右任何可能的方向向圆心靠拢。
因为w1和w2在初始化的时候可以在任何的位置。圆心周围的这一圈蓝色的线,代表损失函数的等高线。也就是w1 w2组成的坐标点在这一圈上的任意位置产生的损失值是相同大小。
随机初始化,因此w1和w2可能出现在圈上的任意位置。显然离坐标系原点(0,0)更远的点(w1,w2)会产生更大的结构风险。因为离坐标系原点更远的点w1 w2坐标的值就会更大。
这里我们再看下黄色圆圈和黄色正方形所围成的面积,就分别代表L1和L2正则化公式所产生的损失值。
左边是L2的,右边是L1的。边缘的圆圈线和直线分别表示他们各自的损失函数值的等高线。
mark看左边的公式就可以知道L2围成一个圆形,L1围成一个正方形。这里加入正则化项之后损失就会由两部分组成,一个是上面的这个蓝色圈圈,一个是下面黄色的部分。
那么在训练时,上面的部分会约束w向着圆心收敛,下面这一部分会约束w1向着原点收敛。
mark最后的解会兼顾这两部分,也就是图中的w星这点。
L2正则项的导数
如何在代码中添加正则化项。我们准备在代码里添加L2正则化项。
markL2正则化项主要是改变了W的更新公式。这里出现了一个变量 拉姆达。
这个变量是我们人工指定的。
首先SGD方法中添加一个lmbda变量,默认值0.0
def SGD(self, training_data, epochs, mini_batch_size, eta,
lmbda = 0.0,
test_data=None):
找到w的更新公式。将lmbda传进去。
# 训练mini_batch
for mini_batch in mini_batches:
self.update_mini_batch(mini_batch, eta, lmbda, n)
同时我们也要传入训练集的总个数n,接着我们要处理update_mini_batch方法。
# 更新mini_batch
def update_mini_batch(self, mini_batch, eta, lmbda, n):
为w的更新方程添加后面那一项。
mark # 更新权重和偏置 Wn+1 = wn - eta * nw
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)]
提取公因式。
mark学习率
学习率 一塔 就是每次挪动中的步长。一塔通常来说给一个比较小的值会更好一些。
mark步子太大会导致迈过谷底。
其他超参数
而由于偏导数方向的改变。你再次挪动又会向着谷底的方向挪动。
mark只是由于步子还是很大,还是会迈过谷底。这样就会像上图一样来回折返。
到底设置为多少,是要根据自己的项目进行判断的。但是小一点的值总是要好一点的。
收敛的比较慢,但是可以使loss的值下到谷底。
Dropout(克服过拟合)
每次训练随机丢弃一些神经元,就相当于整个网络结构发生变化
减少过拟合风险
mark在某些层上临时关闭一些节点,让他们不输入也不输出。原则上选择关闭哪些节点都是随机性的。
在分类阶段,将所有的节点都置于有效的状态
mark就可以把训练中得到的子网络并联起来使用。
交叉熵( Cross Entropy)
学习慢
有时候我们使用相同的学习率,初始化不同的w和b,开始学习的变化率会很慢。
网络在开始学习的时候,整个loss下降的很慢。
举例: 使用梯度下降法来计算w和b, 来看看它是如何学习的
markw=0.6 b=0.9 x=1.0 y=0.82 学习率=0.15
简化后的网络。
mark我们的w和b在不断的变化,我们的cost在不断的下降。
举例: 一个不好的初始化
w=2.0 b=2.0 x=1.0 y=0.2 学习率=0.15
mark可以看到网络的训练开始学习的很慢。w和b的变化很慢。
复杂的神经网络学习很慢。
原因: 其实就是因为偏导很小造成的。
mark mark也就是图中某一点的斜率几乎水平了。
为什么偏导很小,导致学习很慢。
回顾一下之前的网络更新方程。
mark最后一层的偏loss/偏b 等于 预测出来的结果减去网络的标签。点乘于sigmoid的导数。
这里的y0 yi都是一个定值。sigmoid在Z 小于-4 或大于4.水平。斜率为0.
mark想要让网络学习快一点,也就是偏导大一些。我们就要增大sigmoid函数的导数值。
定义
mark之前我们都是使用二次cost来定义我们网络的损失函数。这里我们可以使用交叉熵来定义我们网络的损失函数。
我们可以重新推导一遍偏loss偏w和偏loss偏b的值。
mark如果使用交叉熵函数,而不使用二次损失函数。
mark可以看到最终的公式里就没有sigmoid的导数这一项了。同样的方法我们也可以推导出偏loss/偏b。(避免使用sigmoid的导数)
mark这里的x,n,y都是定制。sigmoid(z)是网络的预测结果。如果网络的预测结果和真实结果接近的话,整个网络的loss值就会减小。
如果偏差比较大的话,loss的值也会因为偏导较大而减小。
markmark可以看到情况1中loss一直随着训练轮数增加而下降。
情况2不再出现学习很慢的情况。
交叉熵编码实现
如何在代码里面添加交叉熵(Cross Entropy)
Network类的初始化时我们可以定义一个损失函数。
def __init__(self, sizes, cost=CrossEntropyCost):
# 损失函数
self.cost = cost
增加一个cost参数。
定义一个二次cost的类
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)
通过staticmethod装饰器,可以直接通过类名.方法名调用(不要实例化:QuadraticCost.fn)
fn里面a是网络预测结果,y是真实的标签。我们返回二次cost函数。
1/2 乘以 (预测值-真实值)的二范数 的平方。
np.linalg.norm
https://blog.csdn.net/lanchunhui/article/details/51004387
再定义另一个delta方法,输入参数为z,预测值a,真实值y
返回(误差) 乘以 sigmoid(z)
这时候再定义交叉熵的类。
class CrossEntropyCost(object):
'''
>>>import numpy as np
>>> a = np.array([[np.nan,np.inf],\
... [-np.nan,-np.inf]])
>>> a
array([[ nan, inf],
[ nan, -inf]])
>>> np.nan_to_num(a)
array([[ 0.00000000e+000, 1.79769313e+308],
[ 0.00000000e+000, -1.79769313e+308]])
'''
@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)
交叉熵的方程:
mark因为在计算出来的数中可能存在无限大和nan值。所以我们通过nan_to_num方法将其进行处理。
网络初始化时,我们可以默认使用CrossEntropyCost这个类
接着我们要将反向更新的代码进行修改:
# 反向更新了
# 计算最后一层的误差
delta = (self.cost).delta(zs[-1], activations[-1], y)
使用self.cost函数来替换掉我们之前写死的二次损失函数。
mark模型的保存与加载
- 网络训练完毕你需要保存,以便在产品上使用。(手写识别模型要识别新的图片)
- 保存网络的结构,权重,偏置,使用的损失函数。
- 使用别人的模型或者对已有模型进行微调。
训练中断之后从该轮模型参数继续往后进行训练。不需要重新开始。
添加模型保存的相关代码
在network类中添加保存模型的方法。需要一个参数filename,保存到哪里
# 保存模型
def save(self, 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()
size是一个列表,定义了一共有多少层,每层有多少个神经元。保存模型权重。
w是numpy的array类型,调用它的tolist方法,把它转换成python的列表类型。
这里保存的是cost的类名字。(CrossEntropyCost),json的dump方法可以将字典保存为字符串。
加载文件
# 加载模型
def load(filename):
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
json的load方法将字符串还原为我们的字典。
需要使用一个python的内置函数getattr
,首先需要传入sys包。
使用里面的modules方法,去获取当前的模型名字,然后使用data里面的cost,去把我们的cost对应的字符串取出来。
这里的意思是说,如果我,我们的这个文件在python里面是别人的另外的文件引入的,那么这个name的名字就是我们脚本的文件名。然后在我们的脚本里面去找到以CrossEntropyCost为名字的class对象。这样就可以使用它了。
实例化一个network。将权重偏置,网络结构进行填充初始化,然后返回这个network。
应用案例—-进阶版本的前馈神经网络代码的手写数字识别
为了提高神经网络的学习速度,添加了参数初始化,添加了L2正则化项,添加了交叉熵cost。使用增加的这部分代码再来做一遍手写数字识别。来看一下准确率有没有提高
mark我们首先多添加一些调试信息
每一轮结束之后,打印一下当前运行到了第几轮
print("Epoch %s training complete" % j)
打印出当前的cost值在训练数据上的表现
cost = self.total_cost(training_data, lmbda)
print("Cost on training data: {}".format(cost))
打印一下网络预测的准确率在训练集上的表现是什么样的。
accuracy = self.accuracy(training_data, convert=True)
print("Accuracy on training data: {} / {}".format(accuracy, n))
增加两个函数,total_cost和accuracy(传入一个参数convert)
当然也可以看一下在测试集上的表现是什么样的
if test_data:
cost = self.total_cost(test_data, lmbda, convert=True)
print("Cost on test data: {}".format(cost))
accuracy = self.accuracy(test_data)
print("Accuracy on test data: {} / {}".format(accuracy, len(test_data)))
测试数据在模型上的准确率是多少?
计算在训练集上的cost和准确率。计算在测试集上的cost和测试集上的准确率。
实现两个函数 total_cost 和 accuracy
之前我们已经有一个函数去计算准确率,evaluate改为accuracy
def accuracy(self, data, convert=False):
if convert:
results = [(np.argmax(self.feedforward(x)), np.argmax(y))
for (x, y) in data]
else:
# 预测结果[0,1,2,3...]中最大的。然后再把真实值保存下来成为一对。
results = [(np.argmax(self.feedforward(x)), y)
for (x, y) in data]
return sum(int(x == y) for (x, y) in results)
默认的convert为flase。通过判断convert来判断我们做什么事情?
为false就表示是测试数据集,我们就跟之前一样。
如果是训练数据集赋值是为真的。这个y有点变化。因为在训练集中的这个y不是一个实数,而是一个十维的向量。如果哪一维是真实的数据就会赋值成1.其他维全部为0[onehot]
定义一个计算损失的函数,兼容测试集和训练集两种,通过convert来区别
def total_cost(self, data, lmbda, convert=False):
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
如果它是真,就表示它是测试数据集。因为测试数据集这个y是一个实数。
我们要把它改变成一个onehot编码之后的数。
通过vectorized_result方法进行onehot编码
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
if __name__ == '__main__':
import mnist_loader
traning_data, validation_data, test_data = mnist_loader.load_data_wrapper()
# net = Network([784, 30, 10])
# net.SGD(traning_data, 30, 10, 0.5, test_data=test_data)
net = Network([784, 60, 10])
net.SGD(traning_data, 30, 10, 0.5, 5.0, test_data=test_data)