相信不少人学过了深度神经网络,但学完之后如果不总结下来的话很多概念仅仅知道个定义,这里给大家总结一篇神经网络的知识点,包含构造神经网络所需的一切组件。
搭建一个神经网络通常需要进行以下步骤:
- Weight initialization 权重初始化;
- Forward propagation 前向传播计算;
- Compute cost 损失计算;
- Backward propagation 后向传播计算;
- Update parameter 更新权重。
注:本文适合有深度学习基础的读者,目的是为大家总结知识点,从而查漏补缺。如果你看不懂里面提到的概念,Don't worry about it,去学习吴恩达老师的DeepLearning.ai 系列课程的第一门课程,学完回来再看本文就会恍然大悟。
本文的大部分内容都是总结于吴恩达老师Neural Networks and Deep Learning课程的内容,部分概念会附上一些简单的代码,但完整代码不宜公开,否则就违反了coursera的荣誉准则了,建议想实操练习的朋友去报名Ng的课程,物超所值。每一个部分都有非常多的细节,看看你都记得多少。
Weight initialization 权重初始化
神经网络有大量的参数,在训练网络前,必须对参数进行初始化,好的初始化可以避免发生梯度弥散、梯度爆炸的情况,加快网络训练。通常有以下几种初始化方式:
- zero, random
零初始化普遍认为都不适用于神经网络中,因为如果对所有神经元都初始化为0的话,后向传播回来的梯度全部一致,导致网络无法学习到有用的权重。但有一个特殊情况,就是当用在逻辑回归的时候,零初始化是没什么问题的,因为不存在隐藏层神经元,权重的梯度也仅与特征x有关,更新的时候可以打破这种对称,从而学得很好。random随机初始化也是比较常用的,一般认为比zero初始化要好,但也有导致梯度爆炸梯度弥散的风险。
- Xavier, he
由于神经网络层数深,每一层的梯度往浅层传播的时候,如果权重数值很大或者很小,经过累乘就会使得梯度变得很大/很小(即爆炸或弥散),如果我们在初始化权重的时候能把权重初始化成只比1大一点点或者小一点点,这样累乘起来的效应就没那么大了。假设我们使用numpy写一个初始化代码,如:
W
我们只需在代码后面乘以一个小数字,比如0.001,或者使用Xavier initialization方法,乘以一个参数Var :
分母表示本层神经元个数和下一层神经元个数,这是一个大于零小于一的小数,用代码表示:
W
或者使用he initialization 方法,乘以这个数:
Forward propagation 前向传播计算
前向传播过程是除了初始化之外最简单的步骤,很多人会有一种错觉,觉得把整个网络结构搭起来了是一件很累很光荣的事,但实际上最麻烦的是数据的预处理及输入(对你没听错,后面再慢慢解释)。keras的作者Francios Chollet也在他的书中说到,神经网络就是一堆高中数学的结合,无非就是线性方程和一些非线性方程的堆叠,去拟合出一个很复杂的函数。只要搭建网络的时候小心一点,注意矩阵的维度,很快就能写好。每一层的前向计算只有两个步骤:linear_forward和activation_forward。
- linear_forward:
- activation_forward:
这里activation forward 用到的g(x),是一些非线性函数,例如relu、sigmoid、tanh,一般来说在隐藏层用tanh、relu或者leaky relu能有很好的效果。而sigmoid一般只在分类任务的最后一层激活使用,如果在隐藏层中使用会导致神经元“死亡”、“饱和”的问题。用numpy很简单就能实现(激活函数用relu为例),注意前向过程要输出一个“cache”,这个cache是一个包含激活值Z和A、权重W和偏置b的tuple数据,这些值将会在后向传播中使用。实现了一层之后,剩下的就是使用for循环堆叠几个这样的层了。
def linear_forward(A, W, b):
Z = np.dot(W, A) + b
cache = (A, W, b)
return Z, cache
def relu(Z):
A = np.maximum(0,Z)
cache = Z
return A, cache
def linear_activation_forward(A_prev, W, b):
Z, linear_cache = linear_forward(A_prev, W, b)
A, activation_cache = sigmoid(Z)
cache = (linear_cache, activation_cache)
return A, cache
Compute cost 损失计算
神经网络需要依靠损失值来产生梯度,从而更新权重。而损失计算要针对你的任务来选择损失函数,多分类问题可以使用categorical_crossentropy_loss,回归问题可以使用MSE loss等等,有些更复杂的任务可以使用多种损失函数的组合,这里就以二分类的对数损失函数来作为例子,损失函数公式为:
为了在代码中的书写方便,这里的模型输出
cost = -1/m * np.sum(Y * np.log(AL) + (1-Y) * np.log(1-AL))
基本上常见的损失函数的代码实现都很简单,但到了CNN里面一些自定义的损失函数实现起来会相当麻烦。
Backward propagation 后向传播计算
我们使用上一步得到的损失值cost来计算梯度,把梯度传播到前面的层当中去,用于权重更新。看到这里的时候有人可能会问:“啊?这一步都要自己实现吗?tensorflow、pytorch那些不是都已经自动计算了吗?”“额,你喜欢就好。”现有的框架的确全部都已经帮你写好了,简单到可以用几行代码 import tensorflow as tf,loss = tf.loss........,optimizer=.......就能把一个网络搭建起来运行,但是你永远都不会知道这几行代码的背后都发生了什么,当出问题的时候,你也不知道到底是在哪里出的问题。而且,假如换了一个框架例如mxnet,它背后的求梯度的实现又与tensorflow不一样,那你怎么办呢?所以只有当你用numpy把所有过程写出来,神经网络的各项细节就会了如指掌,一切的框架都是基于这样的原理,只是实现的方式不一样,当你明白了背后所有的原理,什么框架都不是问题。
神经网络首先要求出损失值cost对于最后一层(第L层)激活AL的梯度,用符号表示为:
dAL = - (np.divide(Y, AL) - np.divide(1 - Y, 1 - AL))
有个小细节,我这里没有乘以1/m,看看下面的公式思考下为什么。
对于每一层来说,首先要接收从后一层计算得到的
1/m又在公式5、6中出现了,为什么?
细节:*号代表element-wise product,其它一律代表矩阵乘法,思考一下为什么会有两种乘法。
我们先写出激活函数的求导公式,也就是上面公式中的
def sigmoid_backward(dA, cache):
Z = cache
s = 1/(1+np.exp(-Z))
dZ = dA * s * (1-s)
return dZ
def relu_backward(dA, cache):
Z = cache
dZ = np.array(dA, copy=True)
dZ[Z <= 0] = 0
return dZ
现在来计算权重更新所需的dW、db,还有要传到前面一层的梯度
def linear_backward(dZ, cache):
A_prev, W, b = cache
m = A_prev.shape[1]
dW = 1/m * np.dot(dZ, A_prev.T)
db = 1/m * np.sum(dZ, axis=1, keepdims=True)
dA_prev = np.dot(W.T, dZ)
return dA_prev, dW, db
def linear_activation_backward(dA, cache, activation):
linear_cache, activation_cache = cache
if activation == "relu":
dZ = relu_backward(dA, activation_cache)
dA_prev, dW, db = linear_backward(dZ, linear_cache)
elif activation == "sigmoid":
dZ = sigmoid_backward(dA, activation_cache)
dA_prev, dW, db = linear_backward(dZ, linear_cache)
return dA_prev, dW, db
忘记激活函数或求导公式的同学,这里有一些有用的链接,好好练习下吧:
深度学习之激活函数表 - CSDN博客blog.csdn.netUpdate parameters 更新权重
在上一步求出所有权重的梯度后,接下来就是更新权重。最常用的梯度下降法就是mini-batch gradient descent,公式如下,α为学习率:
还有很多优化算法,上面的SGD是收敛速度最慢的方法,这里挑选出三个常用的算法:
- SGD with Momentum(SGDM)
相对于SGD,学习率乘以的不再是梯度,而是前面所产生的多个梯度的指数加权平均值。
- RMSprop
RMSprop并不像SGDM那样用学习率直接乘以一个梯度加权平均值,它的梯度将会除以一个值,以此来抑制或者增大梯度值。
因为在计算累积梯度时使用了微分平方加权数,如果某一时刻微分平方的值突然变得很大,分母项就会变得更大,更新的梯度项就会变小,直观理解是“抑制”了这次更新造成的震荡。相反,某时刻的微分平方值比较小(更新幅度小),分母项变小,更新的梯度项就会变大,加大了更新的幅度。
- Adam
Adam是SGDM与RMSprop的结合,与RMSprop的区别在于更新项的分子,RMSprop为dw或者db,Adam则是用了momentum项,如上面的
Momentum项:
均方根项:
更新权重:
附上一个更详细的介绍文章:深度学习优化算法解析(Momentum, RMSProp, Adam)
Training pipeline 训练步骤
上面介绍完所有的组件了,现在把这些组件全部结合起来,我们用伪代码来表示整个训练过程:
X,Y = load_data() # 导入训练数据
parameters = initialize_parameters() # 初始化所有权重
for _ in range(num_epochs): # 循环训练多轮
AL, cache = linear_activation_forward(X, parameters) # 前向传播计算激活
cost = compute_cost(AL, Y) # 计算损失
grads = linear_activation_backward(AL, cost, cache) # 后向传播计算梯度
parameters = update_parameters(parameters, grads, learning_rate) # 更新权重
以上就是神经网络训练的步骤,其实目前所有的深度学习框架都是遵循着以上的流程进行模型训练,这里以tensorflow的伪代码作为例子:
x_train, y_train = load_data()
X = tf.placeholder(......) # 定义数据集占位符
Y = tf.placeholder(......)
W = tf.Variable(......) # 定义变量
b = tf.Variable(......)
init = tf.global_variables_initializer() # 定义初始化变量的方式
Z = tf.add(tf.matmul(W, X), b) # 定义计算图
loss = tf.losses...... # 定义损失函数,Z和Y作为参数
optimizer = tf.train.AdamOptimizer().minimize(loss) # 定义优化器
with tf.Session as sess:
sess.run(init) # 开始执行变量初始化
for _ in range(num_epochs): # 循环训练多轮
_ , cost = sess.run([optimizer, cost], feed_dict={X:x_train, Y:y_train }) # 执行训练
从上面tensorflow的伪代码可以看到,训练流程跟上面纯numpy训练代码的流程是基本一致的(除了后向传播过程不用重新写),只不过tensorflow习惯先定义好所有操作,例如初始化操作、计算图和优化目标,最后再建立一个session来执行所有的操作。万变不离其宗,换成其它深度学习框架也一样,所以当你懂得底层原理后,all you have to do is find the API.
Summary 总结
上面提到的所有概念,全部都是基!础!知!识!,要想成为一个“合格”的算法工程师或者研究员,这些概念是必须精通的(所以面试官也会经常从上面这些知识点来考你哦)。但对于零基础入门者来说,这种自下而上的学习方法可能会比较打击信心,所以很多人也是看着网上一些什么《十分钟使用tensorflow搭建神经网络》《三天tensorflow从入门到精通》的文章来写一些demo,跑一些简单数据来学习使用框架。笔者觉得这并没有什么毛病,因为大家都这样过来的,早期早点出成果可以增强学习兴趣与信心。但入门之后,建议大家还是多巩固基础,努力去了解背后的原理,这样你的技术水平才能上一个新的台阶。
对了,笔者前面挖了一个坑,编程最麻烦的是数据的预处理及输入,相信很多工程师都有这种感受,本文篇幅有限暂时不在本文详细讨论,可到本人的知乎keras专栏去了解一些有关数据预处理及输入的方式,感受一下。
Justin ho:图片数据集太少?看我七十二变,Keras Image Data Augmentation 各参数详解zhuanlan.zhihu.com本文已收录于本人的个人网站,欢迎浏览收藏:https://ijst.me/wp