推荐在我的博客中给我留言,这样我会随时收到你的评论,并作出回复。
在上一篇神经网络的Python实现(二)全连接网络中,已经介绍了神经网络的部分激活函数,损失函数和全连接网络的前馈和反向传播公式及Numpy实现。这篇博文将要详细介绍卷积神经网络的概念,并且进行前馈和反向传播的公式推导及Numpy实现。
卷积神经网络
卷积神经网络(Convolutional Neural Network)非常擅于处理图像任务,它的灵感来自于视觉神经中的感受野
这一概念,卷积神经网络的卷积核(Convolution Kernel) 好似感受野一样去扫描数据。一个卷积神经网络基本包括卷积层、池化层和输出层。
接下来介绍什么是卷积核、卷积神经网络中的卷积是怎么运算的。
卷积和卷积核
卷积神经网络中的卷积操作与数学中的类似。就是输入数据中不同数据窗口的数据和卷积核(一个权值矩阵)作内积的操作。其中卷积核是卷积神经网络中卷积层的最重要的部分。卷积核相当于信息处理中的滤波器,可以提取输入数据的当前特征。卷积核的实质是一个权值矩阵,在下图中的卷积核便是一个权值如下的矩阵(图中黄色色块中的红色数字)
如果并不理解卷积,那么我们来看图中输出的第一行第一列的4是怎么得到的。
原输入数据大小为55,我们要使用33的卷积核来进行卷积,我们使用表示卷积操作。那么图中第一个4的运算过程就可以表达为:
剩下位置的输出就是卷积核在输入矩阵上从左到右从上到下移动一格做如上卷积操作过程的结果。
步长(strides)和填充(padding)
上面例子说到的一格表示的就是步长(strides),步长分为横向步长和纵向步长,步长是多少就表示一次卷积操作之后卷积核移动的距离。知道步长的概念了,我们就可以去计算一下根据输入大小,卷积核大小,我们得到的输出的大小。假设用表示边长,那么:
根据公式当步长为1或是输入大小能够被步长整除时很好处理,无法整除时也就是卷积核移动到最后,输入数据的剩下的部分不足卷积核大小,这时我们会想到要么将输入变大点让它能够整除要么是干脆边界直接丢弃让它能够整除。这两种处理办法对应于填充(padding) 的两种方式,'SAME'和'VALID'。
VALID
其中为向上取整,是宽方向,是长方向, 分别代表输入、输出、卷积核和步长。
超过部分就舍弃不要了。
真正输入大小
SAME
SAME就是在输入周围补0,我们先计算补0后的输出大小:
接下来便根据应得到输出的大小去padding。
其中 是向下取整。
这样0就几乎对称地分布在输入四周。
多通道的卷积
一般卷积神经网络处理的都是3通道或是多通道的图像数据,那么对于多通道如何卷积呢?对于多通道,卷积公式并不变,只是要求卷积核通道与输入通道数一致,不同通道分别做内积,然后不同通道得到的值相加起来作为最后的输出。如图。
对于计算,我们使用的输入和的卷积核举个例子:
可以看到,不论输入通道数是多少最后的输出仍是一个矩阵。在卷积层如果有多个卷积核,每个卷积核会提取一种特征,输出一个二维矩阵。最终的结果就是把这些卷积核的输出看作不同通道。如下图。
下面以图像处理为例,来看一下卷积神经网络的前馈和反向传播。
前向传播
卷积层的前向传播方式与全连接层类似,我们回顾一下全连接层的前向传播:
卷积层只不过把全连接层的矩阵乘法运算换成了卷积运算。详细的步骤如下
知道前一层的输出之后:
- 定义好卷积核数目,卷积核大小,步长和填充方式。根据输入大小,计算输出大小并进行相应的padding,得到了卷积层的输入 。
- 初始化所有卷积和的权重 和偏置
- 根据前向传播的公式(M个通道):
即
计算出卷积层输出,其中是卷积运算、是激活函数。
反向传播
现在已知卷积层的 ,我们通过反向传播算法来计算上一层的 。
我们也先回顾一下反向传播公式,根据链式法则:
要计算的值,必须知道的值,所以根据前向传播公式:
这里我们将 和 拿出来看:
现在就差卷积运算的偏导该如何求,我们先把正确公式写出来,之后再解释:
这里的 表示将卷积核旋转180°,即卷积核左右翻转之后再上下翻转。可以拿张正反内容不一样的纸转一转。然后我们解释为什么卷积的求导就是将卷积核旋转180°再做卷积的结果。
我们拿 大小矩阵作为例子,卷积核大小为 ,步长为1(步长不是1时后面会提到):
上面是前向的卷积运算,我们把它展开来:
这样就变成了简单的运算,根据反向传播公式:
我们就要对每个 求其梯度:
比如 只和 有关,所以:
复杂点的对于为了明显标红的 ,它与 都有关,所以:
类似地我们把所有的 都求出来:
如果你尝试过padding过的卷积运算,你会发现上面的式子就是下面列出的卷积(步长为1):
这里最好动手写一下就可以发现这种运算关系。(这里的周围的0填充宽度是卷积核边长-1)
以上是步长为1时的求解过程,当步长大于1时,我们就需要在 矩阵的值之间填充0来实现步长。
先说结论,每两个 之间需要填充步长-1个0(对应方向上的)。也举个例子来看看是不是这样,步长为2。
展开来:
计算梯度:
即:
注:无论前向步长为多少,旋转后的卷积步长一直是1。
其余计算过程类比全连接层是一样的。至此,卷积层的反向传播就结束了。
下面我们使用Numpy来实现卷积层的前向和反向传播。
CODE
代码是在上一篇全连接网络基础上增加的,继承自Layer
类,使得不同类型的层可以叠加成网络。
卷积层
首先我们定义一个卷积核类,用来实现每个卷积核的卷积计算和前向传播反向传播。
class ConvKernel(Layer):
"""
这里不需要继承自Layer,但是把激活函数求导过程放在了这里,没改所以还是继承了。
"""
def __init__(self, kernel_size, input_shape, strides):
"""
:param kernel_size: 卷积核大小
:param input_shape: 输入大小
:param strides: 步长大小
"""
super().__init__()
self.__kh = kernel_size[0]
self.__kw = kernel_size[1]
self.__input_shape = input_shape
self.__channel = input_shape[2]
self.__strides = strides
# self.__padding = padding
self.__w = np.random.randn(kernel_size[0], kernel_size[1],
input_shape[2]) # np.array([[1,0,1],[0,1,0],[1,0,1]])
self.__output_shape = (int((input_shape[0] - kernel_size[0]) / strides[0]) + 1,
int((input_shape[1] - kernel_size[1]) / strides[1]) + 1)
self.__input = None
self.__output = None
self.__b = np.random.randn(self.__output_shape[0], self.__output_shape[1])
def __flip_w(self):
"""
:return: w after flip 180
"""
return np.fliplr(np.flipud(self.__w))
def __updata_params(self, w_delta, b_delta, lr):
self.__w -= w_delta * lr
self.__b -= b_delta * lr
def __conv(self, _input, weights, strides, _axis=None):
"""
卷积运算
:param _input: 输入
:param weights: 权重
:param strides: 步长
:param _axis: 维度
:return:
"""
if _axis is None: # 矩阵情况
result = np.zeros((int((_input.shape[0] - weights.shape[0]) / strides[0]) + 1,
int((_input.shape[1] - weights.shape[1]) / strides[1]) + 1))
for h in range(result.shape[0]):
for w in range(result.shape[1]):
result[h, w] = np.sum(_input[h * strides[0]:h * strides[0] + weights.shape[0],
w * strides[1]:w * strides[1] + weights.shape[1]] * weights)
else:
result = np.zeros((int((_input.shape[0] - weights.shape[0]) / strides[0]) + 1,
int((_input.shape[1] - weights.shape[1]) / strides[1]) + 1,
self.__input_shape[2]))
for h in range(result.shape[0]):
for w in range(result.shape[1]):
result[h, w, :] = np.sum(_input[h * strides[0]:h * strides[0] + weights.shape[0],
w * strides[1]:w * strides[1] + weights.shape[1]] * weights,
axis=_axis)
return result
def forward_pass(self, X):
self.__input = X
self.__output = self.__conv(X, self.__w, self.__strides) + self.__b
return self.__output
def back_pass(self, error, lr, activation_name='none'):
o_delta = np.zeros((self.__output_shape[0], self.__output_shape[1], self.__channel))
# 将delta扩展至通道数
for i in range(self.__channel):
o_delta[:, :, i] = error
# 根据输入、步长、卷积核大小计算步长
X = np.zeros(
shape=(self.__input_shape[0] + self.__kh - 1, self.__input_shape[1] + self.__kw - 1, self.__channel))
o_delta_ex = np.zeros(
(self.__output_shape[0], self.__output_shape[1],
self.__channel))
# 根据步长填充0
for i in range(o_delta.shape[0]):
for j in range(o_delta.shape[1]):
X[self.__kh - 1 + i * self.__strides[0],
self.__kw - 1 + j * self.__strides[1], :] = o_delta[i, j, :]
# print(o_delta_ex.shape,o_delta.shape)
o_delta_ex[i, j, :] = o_delta[i, j, :]
flip_conv_w = self.__conv(X, self.__flip_w(), (1, 1), _axis=(0, 1))
delta = flip_conv_w * np.reshape(
self._activation_prime(activation_name, self.__input),
flip_conv_w.shape)
w_delta = np.zeros(self.__w.shape)
for h in range(w_delta.shape[0]):
for w in range(w_delta.shape[1]):
if self.__channel == 1:
w_delta[h, w, :] = np.sum(self.__input[h:h + o_delta_ex.shape[0],
w:w + o_delta_ex.shape[1]] * o_delta_ex)
else:
w_delta[h, w, :] = np.sum(self.__input[h:h + o_delta_ex.shape[0],
w:w + o_delta_ex.shape[1]] * o_delta_ex, axis=(0, 1))
self.__updata_params(w_delta, error, lr)
return delta
之后再定义卷积层
class ConvLayer(Layer):
def __init__(self, filters, kernel_size, input_shape, strides, padding, activation, name="conv"):
"""
:param filters: 卷积核个数
:param kernel_size: 卷积核大小
:param input_shape: 输入shape
:param strides: 步长
:param padding: 填充方式
:param activation: 激活函数名
:param name: 层名称
"""
super().__init__()
self.__filters = filters
self.__kernel_size = kernel_size
self.__strides = strides
self.__padding = padding
self.activation_name = activation
self.__input_shape = input_shape # eg 64*64*3
self.__input_padding_shape = input_shape
self.__input = np.zeros(self.__input_shape)
self.name = name
self.flag = False
def _padding_X(self, X):
"""
对输入进行padding
:param X: 输入
:return: 输入padding后的值
"""
if self.__padding == 'SAME':
o_w = int(np.ceil(X.shape[0] / self.__strides[0]))
o_h = int(np.ceil(X.shape[1] / self.__strides[1]))
self.__output_size = (o_w, o_h, self.__filters)
p_w = np.max((o_w - 1) * self.__strides[0] + self.__kernel_size[0] - X.shape[0], 0)
p_h = np.max((o_h - 1) * self.__strides[1] + self.__kernel_size[1] - X.shape[1], 0)
self.p_l = int(np.floor(p_w / 2))
self.p_t = int(np.floor(p_h / 2))
res = np.zeros((X.shape[0] + p_w, X.shape[1] + p_h, X.shape[2]))
res[self.p_t:self.p_t + X.shape[0], self.p_l:self.p_l + X.shape[1], :] = X
return res
elif self.__padding == 'VALID':
o_w = int(np.ceil((X.shape[0] - self.__kernel_size[0] + 1) / self.__strides[0]))
o_h = int(np.ceil((X.shape[1] - self.__kernel_size[1] + 1) / self.__strides[1]))
self.__output_size = (o_w, o_h, self.__filters)
return X[:self.__strides[0] * (o_w - 1) + self.__kernel_size[0],
:self.__strides[1] * (o_h - 1) + self.__kernel_size[1], :]
else:
raise ValueError("padding name is wrong")
def forward_propagation(self, _input):
"""
前向传播,在前向传播过程中得到输入值,并计算输出shape
:param _input: 输入值
:return: 输出值
"""
self.__input = self._padding_X(_input)
self.__input_padding_shape = self.__input.shape
self.__output = np.zeros(self.__output_size)
if not self.flag: # 初始化
self.__kernels = [ConvKernel(self.__kernel_size, self.__input_padding_shape, self.__strides) for _ in
range(self.__filters)] # 由于随机函数,所以不能使用[]*n来创建多个(数值相同)。
self.flag = True
for i, kernel in enumerate(self.__kernels):
self.__output[:, :, i] = kernel.forward_pass(self.__input)
return self._activation(self.activation_name, self.__output)
def back_propagation(self, error, lr):
"""
反向传播过程,对于误差也需要根据padding进行截取或补0
:param error: 误差
:param lr: 学习率
:return: 上一层误差(所有卷积核的误差求平均)
"""
delta = np.zeros(self.__input_shape)
for i in range(len(self.__kernels)):
index = len(self.__kernels) - i - 1
tmp = self.__kernels[index].back_pass(error[:, :, index], lr, self.activation_name)
if self.__padding == 'VALID':
bd = np.ones(self.__input_shape)
bd[:self.__input_padding_shape[0], :self.__input_padding_shape[1]] = tmp
elif self.__padding == 'SAME':
bd = tmp[self.p_t:self.p_t + self.__input_shape[0], self.p_l:self.p_l + self.__input_shape[1]]
else:
raise ValueError("padding name is wrong")
delta += bd
return delta / len(self.__kernels)
以上是卷积层的前向和反向传播实现。需要自己定义好输入维度,不正确会报错。卷积神经网络大多用作图像的分类任务,所以我们还要实现分类任务需要的softmax激活函数和交叉熵(cross entropy) 损失函数。
softmax激活函数和交叉熵(cross entropy)损失函数详细的推导将会放在下一篇中讲解,这里先给出代码实现。
softmax 和 cross entropy
softmax
def _activation(self, name, x):
#···
#···其他激活函数(详细见上篇)
#···
elif name == 'softmax':
x = x - np.max(x) # 防止过大
exp_x = np.exp(x)
return exp_x / np.sum(exp_x)
def _activation_prime(self, name, x):
elif name == 'softmax':
x = np.squeeze(x)
#print(x)
length = len(x)
res = np.zeros((length,length))
# print("length", length)
for i in range(length):
for j in range(length):
res[i,j] = self.__softmax(i, j, x)
return res
def __softmax(self, i, j, a):
if i == j:
return a[i] * (1 - a[i])
else:
return -a[i] * a[j]
cross entropy
def __cross_entropy(self, output, y, loss):
output[output == 0] = 1e-12
if loss:
return -y * np.log(output)
else:
return - y / output
经过卷积层得到的输出一般是多通道的,我们想要接全连接层去进行分类还需要将多通道数据展成一维向量,就需要Flatten层。只是数据位置的变换,看代码就好。
import numpy as np
from Layer import Layer
class FlattenLayer(Layer):
def __init__(self):
super().__init__()
self.__input_shape = None
self.activation_name = 'none'
def forward_propagation(self, _input):
self.__input_shape = _input.shape
return _input.flatten()
def back_propagation(self, error, lr=1):
return np.resize(error, self.__input_shape)
结果
我使用了200张MNIST手写数据,以0.03的学习率训练了100轮之后,对测试集的40张进行了预测,(随便瞎写的模型)结果如下:
epochs 1 / 100 loss : 1.6076663151543635
epochs 2 / 100 loss : 1.51308051414868
epochs 3 / 100 loss : 1.4435877198762985
epochs 4 / 100 loss : 1.4170579907154772
epochs 5 / 100 loss : 1.2782959961456577
epochs 6 / 100 loss : 0.9999002367380303
···
TODO
卷积层常常需要搭配池化层进行数据的降维,所以下一篇会继续实现池化层和讲解Softmax与cross entropy。
参考内容
感谢以下博主的文章,感谢YJango的过程可视化图片。
[1] 能否对卷积神经网络工作原理做一个直观的解释?-知乎
[2] 【TensorFlow】一文弄懂CNN中的padding参数
[3] 卷积神经网络(CNN)反向传播算法