神经网络的Python实现(三)卷积神经网络

推荐在我的博客中给我留言,这样我会随时收到你的评论,并作出回复。


在上一篇神经网络的Python实现(二)全连接网络中,已经介绍了神经网络的部分激活函数,损失函数和全连接网络的前馈和反向传播公式及Numpy实现。这篇博文将要详细介绍卷积神经网络的概念,并且进行前馈和反向传播的公式推导及Numpy实现。

卷积神经网络

卷积神经网络(Convolutional Neural Network)非常擅于处理图像任务,它的灵感来自于视觉神经中的感受野这一概念,卷积神经网络的卷积核(Convolution Kernel) 好似感受野一样去扫描数据。一个卷积神经网络基本包括卷积层池化层输出层

接下来介绍什么是卷积核、卷积神经网络中的卷积是怎么运算的。

卷积和卷积核

卷积神经网络中的卷积操作与数学中的类似。就是输入数据中不同数据窗口的数据和卷积核(一个权值矩阵)作内积的操作。其中卷积核是卷积神经网络中卷积层的最重要的部分。卷积核相当于信息处理中的滤波器,可以提取输入数据的当前特征。卷积核的实质是一个权值矩阵,在下图中的卷积核便是一个权值如下的矩阵(图中黄色色块中的红色数字)

卷积示意图

卷积示意图

如果并不理解卷积,那么我们来看图中输出的第一行第一列的4是怎么得到的。

原输入数据大小为5$\times$5,我们要使用3$\times$3的卷积核来进行卷积,我们使用$\ast$表示卷积操作。那么图中第一个4的运算过程就可以表达为:

剩下位置的输出就是卷积核在输入矩阵上从左到右从上到下移动一格做如上卷积操作过程的结果。

步长(strides)和填充(padding)

步长示意

步长示意图

上面例子说到的一格表示的就是步长(strides),步长分为横向步长和纵向步长,步长是多少就表示一次卷积操作之后卷积核移动的距离。知道步长的概念了,我们就可以去计算一下根据输入大小,卷积核大小,我们得到的输出的大小。假设用$C$表示边长,那么:

根据公式当步长为1或是输入大小能够被步长整除时很好处理,无法整除时也就是卷积核移动到最后,输入数据的剩下的部分不足卷积核大小,这时我们会想到要么将输入变大点让它能够整除要么是干脆边界直接丢弃让它能够整除。这两种处理办法对应于填充(padding) 的两种方式,‘SAME’‘VALID’

VALID

其中$ceil$为向上取整,$w$是宽方向,$h$是长方向, $I,O,k,s$分别代表输入、输出、卷积核和步长。

超过$O_w,O_h$部分就舍弃不要了。

真正输入大小

SAME

same padding

SAME Padding

SAME就是在输入周围补0,我们先计算补0后的输出大小:

接下来便根据应得到输出的大小去padding。

其中 $floor$ 是向下取整

这样0就几乎对称地分布在输入四周。

多通道的卷积

一般卷积神经网络处理的都是3通道或是多通道的图像数据,那么对于多通道如何卷积呢?对于多通道,卷积公式并不变,只是要求卷积核通道与输入通道数一致,不同通道分别做内积,然后不同通道得到的值相加起来作为最后的输出。如图。

多通道卷积

多通道卷积

对于计算,我们使用$2\times2\times2$的输入和$2\times2\times2$的卷积核举个例子:

可以看到,不论输入通道数是多少最后的输出仍是一个矩阵。在卷积层如果有多个卷积核,每个卷积核会提取一种特征,输出一个二维矩阵。最终的结果就是把这些卷积核的输出看作不同通道。如下图。

多通道多卷积核

多通道多卷积核

下面以图像处理为例,来看一下卷积神经网络的前馈和反向传播。

前向传播

卷积层的前向传播方式与全连接层类似,我们回顾一下全连接层的前向传播:

卷积层只不过把全连接层的矩阵乘法运算换成了卷积运算。详细的步骤如下
知道前一层的输出之后:

  1. 定义好卷积核数目,卷积核大小,步长和填充方式。根据输入大小,计算输出大小并进行相应的padding,得到了卷积层的输入 $a^{l-1}$ 。
  2. 初始化所有卷积和的权重 $W$ 和偏置 $b$
  3. 根据前向传播的公式(M个通道):计算出卷积层输出,其中$*$是卷积运算、$\sigma$是激活函数。

反向传播

现在已知卷积层的 $\delta^l$ ,我们通过反向传播算法来计算上一层的 $\delta^{l-1}$ 。
我们也先回顾一下反向传播公式,根据链式法则:

要计算$\delta^l$的值,必须知道$\frac{\partial z^{l}}{\partial z^{l-1}}$的值,所以根据前向传播公式:

这里我们将 $z^{l}$ 和 $z^{l-1}$ 拿出来看:

现在就差卷积运算的偏导该如何求,我们先把正确公式写出来,之后再解释:

这里的 $rot180(W^l)$ 表示将卷积核旋转180°,即卷积核左右翻转之后再上下翻转。可以拿张正反内容不一样的纸转一转。然后我们解释为什么卷积的求导就是将卷积核旋转180°再做卷积的结果。

我们拿 $3\times 3$ 大小矩阵作为例子,卷积核大小为 $2\times 2$,步长为1(步长不是1时后面会提到):

上面是前向的卷积运算,我们把它展开来:

这样就变成了简单的运算,根据反向传播公式:

我们就要对每个 $a$ 求其梯度:

比如 $a_{11}$ 只和 $z_{11}$ 有关,所以:

复杂点的对于为了明显标红的 $a_{22}$ ,它与 $z_{11},z_{12},z_{21},z_{22}$ 都有关,所以:

类似地我们把所有的 $\nabla a$ 都求出来:

如果你尝试过padding过的卷积运算,你会发现上面的式子就是下面列出的卷积(步长为1):

这里最好动手写一下就可以发现这种运算关系。(这里的周围的0填充宽度是卷积核边长-1)

以上是步长为1时的求解过程,当步长大于1时,我们就需要在 $\delta$ 矩阵的值之间填充0来实现步长。

先说结论,每两个 $\delta$ 之间需要填充步长-1个0(对应方向上的)。也举个例子来看看是不是这样,步长为2。

展开来:

计算梯度:

即:

:无论前向步长为多少,旋转后的卷积步长一直是1。

以上是对于单通道的卷积情况。对于多通道时计算方式会有一些不同。举个例子:
假设此时 $l-1$ 层输入为 $14\times14\times6$ 大小的数据,使用$16$个 $5\times5$ 的卷积核进行前向传播,得到了 $10\times10\times16$ 大小的输出。对于输入和输出通道数不同的情况下我们该如何计算误差呢?

$l$ 层的误差 $\delta_{l}$ 的大小为 $l-1$ 层的输出大小即 $10\times10\times16$。通过前向传播过程可以知道输出的每一个通道即是此层的每一个卷积核的输出,所以反向传播时,每一个通道的误差便是每个卷积核对应的误差,大小为 $10\times10\times1$。又因为前向传播时的卷积操作是将那些乘积的结果累和,所以反向传播时要将其还原,所以将 $10\times10\times1$ 的误差除以卷积核的通道数再复制扩展变成 $10\times10\times6$ 大小的误差(每个通道上值均相同)。然后再根据单通道情况的卷积反向传播算法进行计算(此处卷积时通道之间不累和),得到 $14\times14\times6$ 的结果。一共有$16$个卷积核, $l-1$ 层的误差 $\delta_l$ 便是将这$16$个卷积核得到的 $14\times14\times6$ 的结果累和再取平均值。

更新$W$和$b$

我们求出了 $\delta_l$ 的值,现在要更新这层卷积核的参数。根据公式:

可以求得:

上面的公式适用于步长是1的时候,当步长大于1时,我们来看看有什么变化:

依然使用上面步长为2的例子:

展开后:

这里我们对不同的$w$求梯度,可以得到:

把上面的过程写成卷积便是:

对于$b$就把对应卷积核的误差求和便可。至此,卷积层的反向传播就结束了。

下面我们使用Numpy来实现卷积层的前向和反向传播。

CODE

代码是在上一篇全连接网络基础上增加的,继承自Layer类,使得不同类型的层可以叠加成网络。

卷积层

首先我们定义一个卷积核类,用来实现每个卷积核的卷积计算和前向传播反向传播。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
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])
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 = 0 # 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]):
try:
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)
except Warning as e:
print(e)

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 / self.__channel
# 根据输入、步长、卷积核大小计算步长
X = np.zeros(
shape=(self.__input_shape[0] + self.__kh - 1, self.__input_shape[1] + self.__kw - 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 = self.__conv(self.__input, X[self.__kh-1:1-self.__kh,self.__kw-1:1-self.__kw,:], (1,1), _axis=(0, 1))
self.__updata_params(w_delta, np.sum(error), lr)
return delta

之后再定义卷积层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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

1
2
3
4
5
6
def __cross_entropy(self, output, y, loss):
output[output == 0] = 1e-12
if loss:
return -y * np.log(output)
else:
return - y / output

经过卷积层得到的输出一般是多通道的,我们想要接全连接层去进行分类还需要将多通道数据展成一维向量,就需要Flatten层。只是数据位置的变换,看代码就好。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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的学习率训练了20轮之后,对测试集的40张进行了预测,(随便瞎写的简单模型)结果如下:

epochs 1 / 20 loss : 2.2779149872583595
epochs 2 / 20 loss : 0.7755542071620558
epochs 3 / 20 loss : 0.5857308081544076
epochs 4 / 20 loss : 0.6467497169737138
epochs 5 / 20 loss : 0.6403029818053418
···
epochs 16 / 20 loss : 0.44937715533387657
epochs 17 / 20 loss : 0.2910587188924373
epochs 18 / 20 loss : 0.3291737414450256
epochs 19 / 20 loss : 0.23362953800705294
epochs 20 / 20 loss : 0.09193490424383562
acc : 0.775

只用了少数数据集训练20轮,可以看到是能够收敛并且成功预测的。但代码没有优化和Batch,训练时间较长。

完整代码地址

TODO

卷积层常常需要搭配池化层进行数据的降维,所以下一篇会继续实现池化层和讲解Softmax与cross entropy。

参考内容

感谢以下博主的文章,感谢YJango的过程可视化图片。

[1] 能否对卷积神经网络工作原理做一个直观的解释?-知乎
[2] 【TensorFlow】一文弄懂CNN中的padding参数
[3] 卷积神经网络(CNN)反向传播算法

如果您觉得文章有帮助到您,欢迎打赏。