之前学习利用Keras
简单地堆叠卷积网络去构建分类模型的方法,但是对于很深的网络结构很难保证梯度在各层能够正常传播,经常发生梯度消失、梯度爆炸或者其它奇奇怪怪的问题。为了解决这类问题,大佬们想了各种办法,比如最原始的L1,L2
正则化、权重衰减等,但是在深度学习的各种技巧中,批归一化(Batch Normalization,BN
)和残差网(Residual Network,ResNet
)还是比较有名的,看一波。
国际惯例,参考博客:
BN的原始论文
BN的知乎讨论
莫凡大神的BN讲解
Batch Normalization原理与实战
何凯明大佬的caffe-ResNet实现
大佬的keras-ResNet实现
基于keras的Resnet
在Keras
中文文档中有总结过其作用:
以下摘抄一下个人认为论文里面比较重要的语句:
DNN
训练的时候,每层输入的数据分布在不断变化,因为他们之前层的参数在不断更新,这就很大程度上降低训练速度,此时就需要较低的学习率,很小心地初始化模型参数,如果使用饱和非线性函数(saturating nonlinearities
,比如tanh
和sigmoid
)会更难训练,主要是因为两端梯度的影响;这个现象称为内部协方差偏移(internal convariate shift
)现象。
使用小批量训练模型的优势在于,相对于单样本学习,小批量学习的损失梯度是对整个训练集的估计,它的质量随着批大小的上升而提高,此外使用小批量学习的计算比计算m
次单个样本来的更加高效,因为小批量训练可以利用计算机的并行计算。
虽然随机梯度下降简单有效,但是需要很小心调整超参,尤其是学习率和模型参数初始化,并且每一层的输入都受到前面所有层的影响,这个导致训练比较复杂,网络参数任何很小的变化都可能在传播多层以后被放大。但是各层输入的分布又不得不变化,因为各层需要不断调整去适应新的分布(每次输入的样本分布一般不同)。当学习系统的输入分布发生变化,就发生了协方差偏移现象(covariate shift
)。
文章提出一个构想:
假如一个网络结构是这样:
l = F 2 ( F 1 ( u , Θ 1 ) , Θ 2 ) l=F_2(F_1(u,\Theta_1),\Theta_2) l=F2(F1(u,Θ1),Θ2)
那么梯度就是
Θ 2 ← Θ 2 − α m ∑ i = 1 m ∂ F 2 ( x i , Θ 2 ) Θ 2 \Theta_2\leftarrow \Theta_2-\frac{\alpha}{m}\sum_{i=1}^m \frac{\partial F_2(x_i,\Theta_2)}{\Theta_2} Θ2←Θ2−mαi=1∑mΘ2∂F2(xi,Θ2)
( α \alpha α 是学习率, m m m是批大小),这个梯度等价于一个具有输入为 x x x的独立网络 F 2 F_2 F2,因此输入分布可以让训练变得更加高效,比如训练集和测试机的分布相同,这同样适用于子网络。因此随着时间的偏移,保证 x x x的分布固定是有好处的,所以 Θ 2 \Theta_2 Θ2没必要重新调整去弥补 x x x分布的变化,其实说白了,大家一起归一化,固定好分布(均值和方差)。
通常情况下的饱和问题和梯度消失问题能够用ReLU
、小心的初始化和较小学习率来解决,当然,我们也可以修正非线性输入的分布在训练时更加平稳,此时优化器陷入饱和状态的几率会降低,学习速度也会上升。
仅仅是简单地对每层输入的归一化会改变该层所表示的东东,比如对sigmoid
的输入数据归一化,会将其限制在非线性函数的线性区域(因为sigmoid靠近中心部分接近线性激活),解决它就需要保证插入到网络的变换能够代表恒等变换,文章使用缩放因子 γ ( k ) \gamma^{(k)} γ(k)和平移因子 β ( k ) \beta^{(k)} β(k)对归一化的值进行变换:
y ( k ) = γ ( k ) x ^ ( k ) + β ( k ) y^{(k)}=\gamma^{(k)}\hat{x}^{(k)}+\beta^{(k)} y(k)=γ(k)x^(k)+β(k)
实际上,如果 γ ( k ) = V a r [ x ( k ) ] \gamma^{(k)}=\sqrt{Var[x^{(k)}]} γ(k)=Var[x(k)]和 β ( k ) = E ( x ( k ) ) \beta^{(k)}=E(x^{(k)}) β(k)=E(x(k)),那么就是反归一化了,数据直接被恢复成未被归一化的状态。其实我当时在这里有一个疑问:批归一化的目的就是让神经元的激活值在sigmoid梯度较大的地方,那么为啥还要缩放回去,恢复了原始值,那么梯度不又是两端梯度么?就跟吹气球一样,先把气球吹得很大,感觉要炸了,就去将它缩小一点,但是又添加了个偏移,把气球吹回去了。 后来想想,这个问题不难解答,它相当于把较大的东东拆成了较小的东东,然后求导的时候,如果直接对较大的东东求导会发生两端梯度更新缓慢问题,但是如果由多个小东东组合起来,然后对每个小东东求导,梯度就不会出现在两端更新,具体看下面的推导,就可以发现每一个参数的梯度不会那么小。
【注】突然就感觉这个思想很像ResNet
啊,都是为了解决对原始较大值直接求梯度发生两端梯度较小问题,只不过BN
是将大的数据变成了归一化数据+缩放+平移,这些值都比较小,求梯度也不会发生两端梯度的情况;而ResNet
是将大的数据变成了数据+残差项,对这个残差项求梯度很少情况会发生两端梯度现象。
前向计算(注意是针对批数据的同一个维度,而非是一个数据的所有维度):
i n p u t : B = x 1 , ⋯   , m p a r a m : γ , β o u t p u t : y i = B N γ , β ( x i ) μ B = 1 m ∑ i = 1 m x i σ B 2 = 1 m ∑ i = 1 m ( x i − μ B ) 2 x ^ i = x i − μ B σ B 2 + ϵ y i = γ x ^ i + β \begin{aligned} input&: B={x_{1,\cdots,m}}\\ param&:\gamma,\beta \\ output&:y_i=BN_{\gamma,\beta}(x_i) \end{aligned} \\ \begin{aligned} \mu_B&=\frac{1}{m}\sum_{i=1}^m x_i\\ \sigma^2_B&=\frac{1}{m}\sum_{i=1}^m(x_i-\mu_B)^2\\ \hat{x}_i&=\frac{x_i-\mu_B}{\sqrt{\sigma ^2_B+\epsilon}}\\ y_i&=\gamma \hat{x}_i+\beta \end{aligned} inputparamoutput:B=x1,⋯,m:γ,β:yi=BNγ,β(xi)μBσB2x^iyi=m1i=1∑mxi=m1i=1∑m(xi−μB)2=σB2+ϵxi−μB=γx^i+β
反向传播:梯度
模型参数 γ \gamma γ和 β \beta β的梯度
∂ l ∂ γ = ∑ i = 1 m ∂ l ∂ y i ⋅ x ^ i ∂ l ∂ β = ∑ i = 1 m ∂ l ∂ y i \begin{aligned} \frac{\partial l}{\partial \gamma}&=\sum_{i=1}^m\frac{\partial l}{\partial y_i}\cdot \hat{x}_i\\ \frac{\partial l}{\partial \beta}&=\sum_{i=1}^m\frac{\partial l}{\partial y_i} \end{aligned} ∂γ∂l∂β∂l=i=1∑m∂yi∂l⋅x^i=i=1∑m∂yi∂l
链式求导时,需要计算
∂ l ∂ x i = ∂ l ∂ x ^ i ⋅ 1 σ B 2 + ϵ + ∂ l ∂ σ B 2 ⋅ 2 ( x i − μ B ) m + ∂ l ∂ μ B ⋅ 1 m \frac{\partial l}{\partial x_i}=\frac{\partial l}{\partial{\hat{x}_i}}\cdot\frac{1}{\sqrt{\sigma^2_B+\epsilon}}+\frac{\partial l}{\partial \sigma^2_B}\cdot \frac{2(x_i-\mu_B)}{m}+\frac{\partial l}{\partial \mu_B}\cdot\frac{1}{m} ∂xi∂l=∂x^i∂l⋅σB2+ϵ1+∂σB2∂l⋅m2(xi−μB)+∂μB∂l⋅m1
其中
∂ l ∂ x ^ i = ∂ l ∂ y i ⋅ γ ∂ l ∂ σ B 2 = ∑ i = 1 m ∂ l ∂ x ^ i ⋅ ( x i − μ B ) ⋅ − 1 2 ( σ B 2 + ϵ ) − 3 2 ∂ l ∂ μ B = ( ∑ i = 1 m ∂ l ∂ x ^ i ⋅ − 1 σ B 2 + ϵ ) + ∂ l ∂ σ B 2 ⋅ ∑ i = 1 m − 2 ( x i − μ B ) m \begin{aligned} \frac{\partial l}{\partial \hat{x}_i}&=\frac{\partial l}{\partial y_i}\cdot \gamma \\ \frac{\partial l}{\partial \sigma^2_B}&=\sum_{i=1}^m\frac{\partial l}{\partial \hat{x}_i}\cdot(x_i-\mu_B)\cdot\frac{-1}{2}(\sigma^2_B+\epsilon)^{-\frac{3}{2}}\\ \frac{\partial l}{\partial \mu_B}&=\left(\sum_{i=1}^m\frac{\partial l}{\partial \hat{x}_i}\cdot\frac{-1}{\sigma^2_B+\epsilon}\right)+\frac{\partial l}{\partial \sigma^2_B}\cdot\frac{\sum_{i=1}^m-2(x_i-\mu_B)}{m} \end{aligned} ∂x^i∂l∂σB2∂l∂μB∂l=∂yi∂l⋅γ=i=1∑m∂x^i∂l⋅(xi−μB)⋅2−1(σB2+ϵ)−23=(i=1∑m∂x^i∂l⋅σB2+ϵ−1)+∂σB2∂l⋅m∑i=1m−2(xi−μB)
【注】还有一个问题是BN到底是放在激活之前还是激活之后?知乎上的讨论戳这里,原论文的第3.2小节指出实验时采用的是 z = g ( B N ( W u ) ) z=g(BN(Wu)) z=g(BN(Wu))的方式,即先BN再激活。
先看看官方文档描述:
keras.layers.BatchNormalization(axis=-1, momentum=0.99, epsilon=0.001, center=True, scale=True, beta_initializer='zeros', gamma_initializer='ones', moving_mean_initializer='zeros', moving_variance_initializer='ones', beta_regularizer=None, gamma_regularizer=None, beta_constraint=None, gamma_constraint=None)
注意官方文档的一句话Normalize the activations of the previous layer at each batch
,看样子是在激活后再BN。
identity mapping
), 这种构造方法理论上应该能让深层模型产生的训练误差不差于浅层网络的训练误差。但是实验结果发现这种方案不能产生比理论上得到的构造解更好的结果,也就是说没达到理论预期。可以通过添加连接捷径表示恒等映射,如下图所示:
假设这个残差块的输入为 x x x,输出为 y y y,那么,通常情况下:
y = F ( x , W i ) + x y=F(x,{W_i})+x y=F(x,Wi)+x
F + x F+x F+x就代表连接捷径,即对应元素相加; F ( x , W i ) F(x,W_i) F(x,Wi)代表输入经过 F F F几层非线性层的映射结果,图中显示的是两层,类似于
F ( x , W i ) = W 2 × R e l u ( W 1 × x ) F(x,W_i)=W_2\times Relu(W_1\times x) F(x,Wi)=W2×Relu(W1×x)
【注】从图和公式来看,残差块不包含块中最后一层的激活,所以为了使残差块有非线性,必须至少两个层。从加法来看,残差块的输入和输出应该具有相同维度,不然不能相加。
以上说的是通常情况,还有不通常情况就是假设残差块的非线性映射部分输出的维度比输入维度小,那么对应元素相加就无法实现,论文就给出了想要维度匹配时的操作:
y = F ( x , W i ) + W s × x y=F(x,{W_i})+W_s\times x y=F(x,Wi)+Ws×x
没错,就是乘了一个矩阵 W s W_s Ws,变换 x x x的维度就完事,而且因为是线性操作,影响不大。
先看看何凯明大佬在caffe中搭建的ResNet是啥样的:
两类残差块,一类是在左边捷径连接的时候接了个分支,第二类是直接恒等映射过来。
但是大致能知道残差块大致包含了两部分,一部分有较多的层块,一部分有较少的甚至是一个或者零个层块,除此之外还有一些细节就是,每个组成残差块的每个层块构造是:卷积->BN->缩放->Relu
,为了保证维度相加的可能性,尽量使用卷积核大小为(1,1)
步长为1,填充为0,或者卷积核大小为(3,3)
步长为1,填充为1,文章的右边三个卷积块使用的卷积核大小分别是 1 , 3 , 1 1,3,1 1,3,1,卷积核个数不同,计算卷积后特征图大小公式是:
n − m + 2 p S + 1 \frac{n-m+2p}{S}+1 Sn−m+2p+1
n是图像某个维度,m是对应的卷积核维度,p是对应的填充维度,S是步长
接下来在keras
中搞事情。
#ResNet-第一类:有侧路卷积(大小1);主路卷积用1,3,1的卷积块(conv-BN-Relu)
def conv_block(input_x,kn1,kn2,kn3,side_kn):
#主路:第一块
x=Conv2D(filters=kn1,kernel_size=(1,1))(input_x)
x=BatchNormalization(axis=-1)(x)
x=Activation(relu)(x)
#主路:第二块,注意padding
x=Conv2D(filters=kn2,kernel_size=(3,3),padding='same')(x)
x=BatchNormalization(axis=-1)(x)
x=Activation(relu)(x)
#主路:第三块
x=Conv2D(filters=kn3,kernel_size=(1,1))(x)
x=BatchNormalization(axis=-1)(x)
x=Activation(relu)(x)
#侧路
y=Conv2D(filters=side_kn,kernel_size=(1,1))(input_x)
y=BatchNormalization(axis=-1)(y)
y=Activation(relu)(y)
#捷径
output=keras.layers.add([x,y])
#再激活一次
output=Activation(relu)(output)
return output
#ResNet-第二类:没有侧路卷积;主路卷积用1,3,1的卷积块(conv-BN-Relu)
def identity_block(input_x,kn1,kn2,kn3):
#主路:第一块
x=Conv2D(filters=kn1,kernel_size=(1,1))(input_x)
x=BatchNormalization(axis=-1)(x)
x=Activation(relu)(x)
#主路:第二块,注意padding
x=Conv2D(filters=kn2,kernel_size=(3,3),padding='same')(x)
x=BatchNormalization(axis=-1)(x)
x=Activation(relu)(x)
#主路:第三块
x=Conv2D(filters=kn3,kernel_size=(1,1))(x)
x=BatchNormalization(axis=-1)(x)
x=Activation(relu)(x)
#捷径
output=keras.layers.add([x,input_x])
#再激活一次
output=Activation(relu)(output)
return output
可以这里和这里的代码,主要是基于何凯明大佬的ResNet50
写的结构:
引入相关包
import tensorflow as tf
import tensorflow.keras as keras
from tensorflow.keras.layers import Conv2D,AveragePooling2D,MaxPooling2D,BatchNormalization,Activation,Flatten,Dense
from tensorflow.keras.activations import relu
import numpy as np
基于以上的BN
和两类ResNet
块结构构建ResNet50
#按照何凯明大佬的ResNet50构建模型
def ResNet50():
input_data= keras.layers.Input(shape=(28,28,1))
x=Conv2D(filters=64,kernel_size=(7,7),data_format='channels_last')(input_data)#由于不知道怎么padding=3,所以暂时不适用padding和stride
x=BatchNormalization(axis=-1)(x)
x=Activation(relu)(x)
x=MaxPooling2D(pool_size=(3,3),strides=(2,2))(x)
x=conv_block(x,64,64,256,256)#Res2a:有侧路
x=identity_block(x,64,64,256)#Res2b:无侧路
x=identity_block(x,64,64,256)#Res2c:无侧路
x=conv_block(x,128,128,512,512)#Res3a:有侧路
x=identity_block(x,128,128,512)#Res3b:无侧路
x=identity_block(x,128,128,512)#Res3c:无侧路
x=identity_block(x,128,128,512)#Res3d:无侧路
x=conv_block(x,256,256,1024,1024)#Res4a:有侧路
x=identity_block(x,256,256,1024)#Res4b:无侧路
x=identity_block(x,256,256,1024)#Res4c:无侧路
x=identity_block(x,256,256,1024)#Res4d:无侧路
x=identity_block(x,256,256,1024)#Res4d:无侧路
x=identity_block(x,256,256,1024)#Res4e:无侧路
x=identity_block(x,256,256,1024)#Res4f:无侧路
x=conv_block(x,512,512,2048,2048)#Res5a:有侧路
x=identity_block(x,512,512,2048)#Res5b:无侧路
x=identity_block(x,512,512,2048)#Res5c:无侧路
x=AveragePooling2D(pool_size=(7,7),strides=1)(x)
x=Flatten()(x)
x=Dense(units=10)(x) #mnist的类别数目
x=Activation(keras.activations.softmax)(x)
model=keras.models.Model(inputs=input_data, outputs=x)
return model
读手写数字
mnist_data=keras.datasets.mnist
(train_x,train_y),(test_x,test_y)=mnist_data.load_data()
train_y=keras.utils.to_categorical(train_y,10)
test_y=keras.utils.to_categorical(test_y,10)
train_x=train_x/255.0
test_x=test_x/255.0
train_x=train_x[...,np.newaxis]
test_x=test_x[...,np.newaxis]
构建模型并训练
model=ResNet50()
mnist_data=keras.datasets.mnist
(train_x,train_y),(test_x,test_y)=mnist_data.load_data()
train_y=keras.utils.to_categorical(train_y,10)
test_y=keras.utils.to_categorical(test_y,10)
train_x=train_x/255.0
test_x=test_x/255.0
train_x=train_x[...,np.newaxis]
test_x=test_x[...,np.newaxis]
model.compile(optimizer=tf.keras.optimizers.Adam(),loss=keras.losses.categorical_crossentropy,metrics=['accuracy'])
model.fit(train_x,train_y,batch_size=20,epochs=20)
太久了,我就不训练了,而且网络太大,没事就OutOfMemory
:
Epoch 1/20
9080/60000 [===>..........................] - ETA: 53:37 - loss: 14.3559 - acc: 0.1073
预测的话可以参考之前的博客model.predict
之类的
在深度神经网络中常用的两个解决梯度消失问题的技巧已经学了,后面再继续找找案例做,其实为最想要的是尝试如何把算法移植到手机平台,最大问题是模型调用和平台移植,目前可采用的方法有:
unity
在做APP
上效果还不错,能各种移植,且TensorFlow
支持C#
ensorflow Lite
也抽时间看看OpenCV
有dnn
模块可以调用TensorFlow
模型,但是目前还没学会如何将自己的TensorFlow
模型封装好,到OpenCV
调用,只不过官方提供的模型可以调用,自己的模型一直打包出问题。