本周小项目的主题是卷积神经网络的理论和实现。相比于前一篇添加了卷积层、池化层、反向传播以及辅助函数。理论描述中并未引入通常意义上的“卷积”以免带来困惑,因为机器学习中的“卷积”只是一个“滑动互相关”过程。代码部分是在全链接代码的基础上完成的,可以用于搭建 Yann Lecun1998年文章中所描述的用于识别手写字符的卷积神经网络。
文章目的在于解释算法,所以 TensorFlow、Caffe 等机器学习框架不会出现在正文中。但这不代表文中算法与相关机器学习库没有可比性,本章中卷积(互相关实现)、梯度计算结果均与 TensorFlow 相同。文末会给出文本实践过程中与TensorFlow的对比以及相关代码。
Python代码在速度上是有欠缺的,后续文章会使用并行(GPU+CPU)代码进行优化。
1. 阅读建议
- 推荐阅读时间:40min
- 推荐阅读文章:Lecun Y, Bottou L, Bengio Y, et al. Gradient-based learning applied to document recognition[J]. Proceedings of the IEEE, 1998, 86(11):2278-2324.
2. 软件环境
- Python3
- Numpy
3. 前置基础
- 导数
- 矩阵运算
3.1 符号说明
- 矩阵元素:$a_{ijkr}$或$A_{i,j,q,r}$
- 矩阵分片:$a_{a:b, c:d}$
- 对集合中所有元素进行求和:$\sum_{u,v\in A}a_{u}b_v$
- 带有自变量 x 与 w 的函数,可以认为 x 为输入数据,w 为可训练参数:$f(x;w)$
- 对自变量 w 求梯度:$\nabla_w f(x;w)=\{\frac{\partial f}{\partial w_1}, \cdots,\frac{\partial f}{\partial w_n} \}$
4. 数据描述
- 数据来源:Yann LeCun 文章
- 数据下载:http://yann.lecun.com/exdb/mnist/
- 数据描述:MNIST(Mixed National Institute of Standards and Technology database)是一个计算机视觉数据集,它包含 70000 张手写数字的灰度图片,其中每一张图片包含 28 X 28 个像素点。保存形式为长度为 784 的向量。
5. 理论部分
所有的深度神经网络层都分为向前传播与反向传播部分。向前传播产生每一层输出,反向传播产生此层可训练参数的导数以及此层向前一层所“传播”的误差 $e^l$,传递误差就是此层输出 $x^{l+1}$ 对此层输入的导数 $x^l$:
1.1 式所描述的公式适用于所有的前馈神经网络的训练过程,前馈神经网络(全链接网络、卷积神经网络)的输入和输出可以看成是函数嵌套的形式:
1.2 之中,每一个嵌套的函数都可以看成是前馈神经网络的一个计算单元,卷积神经网络的计算单元包括:卷积层(仅卷积过程,加入偏置、通过激活函数均不包含在内)、池化层、全链接层、激活函数、加入偏置等。下面对这几个计算单元的向前、反向传播过程进行描述:
5.1 卷积层向前传播
首先需要明确卷积层输入、输出的形式,对于处理图像问题来说,卷积层输入矩阵形式为 [BATCHSIZE, Height, Weight, Chanel],每个 [Height, Weight] 称之为一个特征图。很多人都喜欢叫的“卷积”神经网络,在实现过程之中只是一个滑动互相关操作,互相关的矩阵称之为卷积核,其矩阵形式为 [KernelSize, KernelSize, OldChanel, NewChanel]。Kernelsize 为卷积核心大小,卷积核心大小与图形特征尺度是有关的。OldChanel 为旧的特征图数量,NewChanel 为新的特征图数量,新特征图数量是自行定义的。卷积层计算可以写为:
1.3 式描述了卷积过程,W 为卷积核心,$x^l$ 为输入,$x^{l+1}$ 为输出,m 为卷积核心大小。stride 为滑动互相关过程中卷积核心每次在旧的特征图上移动步数。
5.2 池化层向前传播
池化实际上是一个降采样的过程,说直白一点就是将一个图像(特征图)的分辨率降低一些。这可以有效的减少后续计算复杂度,还有一个目的在于增加感受野
名词-感受野:某一层输出神经元所对应的所有输入神经元个数。举个例子,1.3 式中 m=3 则感受野为 3,以相同的参数 (m=3,stride=1),再次叠加一层此时输出层的感受野为 5。如果第二层的参数改为 (m=3,stride=2),此时输出层感受野大小为 7。
池化层可以通过在卷积层中选取一个大于 1 的 stride 来完成相似的效果。这里用最大池化(maxpool)作为示例:
ks 为池化层之中定义的 kernelSize,本文中令 ks=stride。
5.3 卷积层反向传播
卷积层误差反向传播过程:
卷积层可训练参数:
5.4 池化层反向传播
池化层反向传播过程之中只需知道具体哪一个位置取得最大值即可,误差沿取得极大值的神经元传播,其他部位反向传播误差为0。
5.5 展开层反向传播
卷积神经网络与全链接网络之间需要一个展开层过渡,使得矩阵符合卷积、全链接的输入与输出。因此误差传播过程仅为对矩阵进行的变换:
$$e^l=reshape(e^{l+1}, [BATCHSIZE, H, W, Chanel])$$
5.6 偏置项导数
加入偏置项与全链接类似,此步之中仅需计算导数即可:
6. 代码部分
6.1 结构分析
可以看到将神经网络拆分成几个计算层后,每一层需要完成正向传播与反向传播两个函数,反向传播又有两个矩阵需要计算:反向传播误差与可训练参数的导数。此部分代表与全链接网络大部分代码是通用的,相比于全链接实践添加了卷积层、Flatten 层与新的激活函数“ReLU”:
6.2 卷积层
卷积层计算过程之中最繁琐的地方在于 padding。如果选择了不同参数则:
此时必然会有部分元素超出,需要对超出部位进行补 0。
def _conv2d(self, inputs, filters, par): stride, padding = par B, H, W, C = np.shape(inputs) K, K, C, C2 = np.shape(filters) if padding == "SAME": """ 选择padding=SAME需要对矩阵边缘进行补0 """ H2 = int((H-0.1)//stride + 1) W2 = int((W-0.1)//stride + 1) pad_h_2 = K + (H2 - 1) * stride - H pad_w_2 = K + (W2 - 1) * stride - W pad_h_left = int(pad_h_2//2) pad_h_right = int(pad_h_2 - pad_h_left) pad_w_left = int(pad_w_2//2) pad_w_right = int(pad_w_2 - pad_w_left) X = np.pad(inputs, ((0, 0), (pad_h_left, pad_h_right), (pad_w_left, pad_w_right), (0, 0)), 'constant', constant_values=0) elif padding == "VALID": H2 = int((H - K)//stride + 1) W2 = int((W - K)//stride + 1) X = inputs else: raise "parameter error" out = np.zeros([B, H2, W2, C2]) for itr1 in range(B): for itr2 in range(H2): for itr3 in range(W2): for itrc in range(C2): itrh = itr2 * stride itrw = itr3 * stride out[itr1, itr2, itr3, itrc] = np.sum(X[itr1, itrh:itrh+K, itrw:itrw+K, :] * filters[:,:,:,itrc]) return out def _d_conv2d(self, in_error, n_layer, layer_par=None): stride, padding = self.layer[n_layer][1] inputs = self.outputs[n_layer] filters = self.value[n_layer] B, H, W, C = np.shape(inputs) K, K, C, C2 = np.shape(filters) if padding == "SAME": H2 = int((H-0.1)//stride + 1) W2 = int((W-0.1)//stride + 1) pad_h_2 = K + (H2 - 1) * stride - H pad_w_2 = K + (W2 - 1) * stride - W pad_h_left = int(pad_h_2//2) pad_h_right = int(pad_h_2 - pad_h_left) pad_w_left = int(pad_w_2//2) pad_w_right = int(pad_w_2 - pad_w_left) X = np.pad(inputs, ((0, 0), (pad_h_left, pad_h_right), (pad_w_left, pad_w_right), (0, 0)), 'constant', constant_values=0) elif padding == "VALID": H2 = int((H - K)//stride + 1) W2 = int((W - K)//stride + 1) X = inputs else: raise "parameter error" error = np.zeros_like(X) for itr1 in range(B): for itr2 in range(H2): for itr3 in range(W2): for itrc in range(C2): itrh = itr2 * stride itrw = itr3 * stride error[itr1, itrh:itrh+K, itrw:itrw+K, :] += in_error[itr1, itr2, itr3, itrc] * filters[:,:,:,itrc] self.d_value[n_layer] = np.zeros_like(self.value[n_layer]) for itr1 in range(B): for itr2 in range(H2): for itr3 in range(W2): for itrc in range(C2): itrh = itr2 * stride itrw = itr3 * stride self.d_value[n_layer][:, :, :, itrc] += in_error[itr1, itr2, itr3, itrc] * X[itr1, itrh:itrh+K, itrw:itrw+K, :] return error[:, pad_h_left:-pad_h_right, pad_w_left:-pad_w_right, :] def conv2d(self, filters, stride, padding="SAME"): self.value.append(filters) self.d_value.append(np.zeros_like(filters)) self.layer.append((self._conv2d, (stride, padding), self._d_conv2d, None)) self.layer_name.append("conv2d")
6.3 偏置项修改
def _bias_add(self, inputs, b, *args, **kw): return inputs + b def _d_bias_add(self, in_error, n_layer, *args, **kw): shape = np.shape(in_error) dv = [] if len(shape) == 2: self.d_value[n_layer] = np.sum(in_error, axis=0) else: dv = np.array([np.sum(in_error[:, :, :, itr]) for itr in range(shape[-1])]) self.d_value[n_layer] = np.squeeze(np.array(dv)) return in_error def bias_add(self, bias, *args, **kw): self.value.append(bias) self.d_value.append(np.zeros_like(bias)) self.layer.append((self._bias_add, None, self._d_bias_add, None)) self.layer_name.append("bias_add")
6.4 展开层
def _flatten(self, X, *args, **kw): B = np.shape(X)[0] return np.reshape(X, [B, -1]) def _d_flatten(self, in_error, n_layer, layer_par): shape = np.shape(self.outputs[n_layer]) return np.reshape(in_error, shape) def flatten(self): self.value.append([]) self.d_value.append([]) self.layer.append((self._flatten, None, self._d_flatten, None)) self.layer_name.append("flatten")
6.5 最大池化层
def _maxpool(self, X, _, stride, *args, **kw): B, H, W, C = np.shape(X) X_new = np.reshape(X, [B, H//stride, stride, W//stride, stride, C]) return np.max(X_new, axis=(2, 4)) def _d_maxpool(self, in_error, n_layer, layer_par): stride = layer_par X = self.outputs[n_layer] Y = self.outputs[n_layer + 1] expand_y = np.repeat(np.repeat(Y, stride, axis=1), stride, axis=2) expand_e = np.repeat(np.repeat(in_error, stride, axis=1), stride, axis=2) return expand_e * (expand_y == X) def maxpool(self, stride, *args, **kw): self.value.append([]) self.d_value.append([]) self.layer.append((self._maxpool, stride, self._d_maxpool, stride)) self.layer_name.append("maxpool")
6.6 修正线性激活函数
def _relu(self, X, *args, **kw): return (X + np.abs(X))/2. def _d_relu(self, in_error, n_layer, layer_par): X = self.outputs[n_layer] drelu = np.zeros_like(X) drelu[X>0] = 1 return in_error * drelu
6.7 代码其他部分
代码与全链接层共享代码,因此不单独列出。
7. 程序运行
程序运行过程,首先是对网络进行描述:
# 初始化值cw1 = np.random.uniform(-0.1, 0.1, [5, 5, 1, 32])cb1 = np.zeros([32])fw1 = np.random.uniform(-0.1, 0.1, [7 * 7 * 32, 10])fb1 = np.zeros([10])# 建立模型mtd = NN()mtd.conv2d(cw1, 1)mtd.bias_add(cb1)mtd.relu()mtd.maxpool(4)mtd.flatten()mtd.matmul(fw1)mtd.bias_add(fb1)mtd.sigmoid()mtd.loss_square()# 训练for itr in range(100): ... mtd.fit(inx, iny)
7.1 运行结果
类中加入语句
def model(self): for idx, itr in enumerate(self.layer_name): print("Layer %d: %s"%(idx, itr))
用于输出模型:
Layer 0: conv2dLayer 1: bias_addLayer 2: reluLayer 3: maxpoolLayer 4: flattenLayer 5: matmulLayer 6: bias_addLayer 7: sigmoidLayer 8: loss
迭代 100 次后精度 92%。
接下来需要修改什么
- 模型太简单
- 损失函数并不合适
- 训练过程迭代缓慢
- 其他梯度迭代方法比如Adam引入
附录-与 TensorFlow 对比方法
对比过程使用的方式为计算每一步所产生的梯度与变量取值进行对比:
import tensorflow as tf...#使用可训练参数 W1、B1、W2、B2 搭建与本文相同的网络,初始化数值需要与自己搭建网络一致。...sess = tf.Session()sess.run(tf.global_variables_initializer())#梯度下降法opt = tf.train.GradientDescentOptimizer(0.01)#计算梯度grad = opt.compute_gradients(loss, [W1, B1, W2, B2])#执行w=w+eta*dw过程step = opt.apply_gradients(grad)#获取变量取值for itr in range(3): ... parlist = sess.run([W1, B1, W2, B2]) print(sess.run(net, feed_dict={X:images, Y:labels})) grdlist = sess.run(grad, feed_dict={X:images, Y:labels}) # 输出变量取值与计算梯度的均值和方差 for par, grd in zip(parlist, grdlist): print("step%d, %3.7f, %3.7f, %3.7f, %3.7f"%(itr, np.mean(par), np.std(par), np.mean(grd[0]), np.(grd[0]))) sess.run(step, feed_dict={X1:images, Y1:labels})
本文首发于GitChat,未经授权不得转载,转载需与GitChat联系。
阅读全文: http://gitbook.cn/gitchat/activity/5af6927fb9b1d755e3052e19
您还可以下载 CSDN 旗下精品原创内容社区 GitChat App ,阅读更多 GitChat 专享技术内容哦。