用Python手撕CNN

上一节介绍了ANN,ANN可以说是最简单的神经网络了,但是ANN却包含了神经网络需要的基础框架,之后的网络都是在此基础上删删补补。本节介绍CNN(Convolutional Neural Network,卷积神经网络),CNN常被用于图像识别、语音识别等各种场合。

CNN的整体结构

CNN和ANN一样,可以像堆乐高积木一样一层一层的来组装,在CNN中,新出现了卷积层(Convolution)和池化层(Pooling)。

卷积层

在ANN中出现的Affine层也被称为全连接层,即相邻层的所有神经元之间都有连接,但是全连接层有个问题,就是不能表征数据的形状,举个例子,在图像识别中,图像通常是由长、高和通道来表示的3维数据,在全连接层中,需要将这3维数据拉平成1维数据,这样就会丢失了图像关于形状的相关信息。

而卷积层则保持形状不变,当输入数据是图像时,卷积层会以3维数据的形式接收输入数据,并同样以3维数据的形式输出至下一层,因此,在 CNN 中,可能会正确理解到图像具有的形状信息。

卷积运算

卷积层进行的处理就是卷积运算,卷积运算相当于图像处理中的滤波器运算核运算,以下面的一个例子来介绍一下卷积运算

在这个例子中,输入数据是有高长方向的形状的数据,滤波器也一样,有高长方向上的维度。假设用(height, width)表示数据和滤波器的形状,则在本例中,输入大小是 (4, 4),滤波器大小是 (3, 3),输出大小是 (2, 2),下图具体介绍了卷积运算的计算顺序。

对于输入数据,卷积运算以一定间隔滑动滤波器的窗口,将各个位置上滤波器的元素和输入的对应元素相乘,然后再求和,如果存在偏置的话,则偏置会被加到滤波器的所有元素上。

填充

在进行卷积层的处理之前,有时要向输入数据的周围填入固定的数据(比如 0 等),这称为填充(padding),是卷积运算中经常会用到的处理。在下图中,对大小为 (4, 4) 的输入数据应用了幅度为 1 的填充。“幅度为 1 的填充”是指用幅度为 1 像素的 0 填充周围。

通过填充,大小为 (4, 4) 的输入数据变成了 (6, 6) 的形状。然后,应用大小为 (3, 3) 的滤波器,生成了大小为 (4, 4) 的输出数据。

步幅

应用滤波器的位置间隔称为步幅(stride),之前的例子中步幅都是 1,如果将步幅设为 2,,应用滤波器的窗口的间隔变为 2 个元素,就像下图所示

综合填充和步幅可以发现,增大步幅后,输出大小会变小。而增大填充后,输出大小会变大,假设输入大小为 (H, W),滤波器大小为 (FH, FW),输出大小为 (OH, OW),填充为 P,步幅为 S,则输出大小的计算公式如下所示

OH=H+2PFHS+1OW=W+2PFWS+1OH=\frac{H+2P-FH}{S}+1 \\ OW=\frac{W+2P-FW}{S}+1

多维数据的卷积运算

多维数据的卷积运算与2维数据的卷积运算同理,只是滤波器的维度和输入数据的维度相同,如下图所示,通道数为 C、高度为 H、长度为 W 的数据的形状可以写成(C, H, W),滤波器的维度相同,为(C, FH, FW),但是,为了要在通道方向上也拥有多个卷积运算的输出,就需要用到多个滤波器(权重),假设滤波器的个数为FN,则输出的特征图也生成了 FN 个,果将这 FN 个特征图汇集在一起,就得到了形状为 (FN, OH, OW) 的方块,并且每个通道只有一个偏置,偏置的形状是 (FN, 1, 1),滤波器的输出结果的形状是 (FN, OH, OW),这两个方块相加时,要对滤波器的输出结果 (FN, OH, OW) 按通道加上相同的偏置值,将这个方块传给下一层,就是 CNN 的处理流。

池化层

池化层的作用是缩小高、长方向上的空间运算,如下图所示进行将 2 × 2 的区域集约成 1 个元素的处理,缩小空间大小。

池化层可以分为最大池化和平均池化等,上图中是最大池化,即选取目标区域中最大的元素作为输出,池化层具有以下特征:

  • 没有要学习的参数:池化层和卷积层不同,没有要学习的参数,池化只是从目标区域中取最大值(或者平均值),所以不存在要学习的参数
  • 通道数不发生变化:经过池化运算,输入数据和输出数据的通道数不会发生变化
  • 对微小的位置变化具有鲁棒性(健壮):输入数据发生微小偏差时,池化仍会返回相同的结

卷积层和池化层的实现

前面简单的介绍了卷积层和池化层的原理,现在来用Python来实现这两个层,同ANN,这两个层也需要forwardbackward方法。

卷积层的实现

在利用Python实现卷积层时,用到了im2col函数,im2col函数将输入数据展开以适合滤波器(权重),不需要我们用重复好几层for循环语句来实现,im2col函数的定义为

# input_data:由(数据量,通道,高,长)的 4 维数组构成的输入数据
# filter_h:滤波器的高
# filter_w:滤波器的长
# stride:步幅
# pad:填充
im2col (input_data, filter_h, filter_w, stride=1, pad=0)

使用im2col来实现卷积层的代码如下

class Convolution:
def __init__(self, W, b, stride=1, pad=0):
self.W = W
self.b = b
self.stride = stride
self.pad = pad

def forward(self, x):
FN, C, FH, FW = self.W.shape
N, C, H, W = x.shape
out_h = int(1 + (H + 2*self.pad - FH) / self.stride)
out_w = int(1 + (W + 2*self.pad - FW) / self.stride)

col = im2col(x, FH, FW, self.stride, self.pad)
col_W = self.W.reshape(FN, -1).T # 滤波器的展开
out = np.dot(col, col_W) + self.b

out = out.reshape(N, out_h, out_w, -1).transpose(0, 3, 1, 2)

return out

def backward(self, dout):
FN, C, FH, FW = self.W.shape
dout = dout.transpose(0,2,3,1).reshape(-1, FN)

self.db = np.sum(dout, axis=0)
self.dW = np.dot(self.col.T, dout)
self.dW = self.dW.transpose(1, 0).reshape(FN, C, FH, FW)

dcol = np.dot(dout, self.col_W.T)
dx = col2im(dcol, self.x.shape, FH, FW, self.stride, self.pad)

return dx

注意,在forward的实现中,最后会将输出大小转换为合适的形状。转换时使用了 NumPy 的 transpose函数,transpose会更改多维数组的轴的顺序,在进行卷积层的反向传播时,必须进行 im2col的逆处理,卷积层的反向传播和上一节ANN的Affine 层的实现方式一样。

池化层的实现

池化层和卷积层有点不同,就是在池化的情况下,在通道方向上是独立的,池化需要按通道单独展开,就如下图所示

池化层的代码实现也是大同小异

class Pooling:
def __init__(self, pool_h, pool_w, stride=1, pad=0):
self.pool_h = pool_h
self.pool_w = pool_w
self.stride = stride
self.pad = pad

self.x = None
self.arg_max = None

def forward(self, x):
N, C, H, W = x.shape
out_h = int(1 + (H - self.pool_h) / self.stride)
out_w = int(1 + (W - self.pool_w) / self.stride)

col = im2col(x, self.pool_h, self.pool_w, self.stride, self.pad)
col = col.reshape(-1, self.pool_h*self.pool_w)

arg_max = np.argmax(col, axis=1)
out = np.max(col, axis=1)
out = out.reshape(N, out_h, out_w, C).transpose(0, 3, 1, 2)

self.x = x
self.arg_max = arg_max

return out

def backward(self, dout):
dout = dout.transpose(0, 2, 3, 1)

pool_size = self.pool_h * self.pool_w
dmax = np.zeros((dout.size, pool_size))
dmax[np.arange(self.arg_max.size), self.arg_max.flatten()] = dout.flatten()
dmax = dmax.reshape(dout.shape + (pool_size,))

dcol = dmax.reshape(dmax.shape[0] * dmax.shape[1] * dmax.shape[2], -1)
dx = col2im(dcol, self.x.shape, self.pool_h, self.pool_w, self.stride, self.pad)

return dx

CNN的实现

已经实现了卷积层和池化层,现在来组合这些层,搭建进行手写数字识别的 CNN。

简单CNN的实现

首先构建一个3层的CNN网络结果,网络结果如下所示

可以看出网络的构成是Convolution - ReLU - Pooling -Affine - ReLU - Affine - Softmax,整个步骤也和之前介绍的ANN一样,这里就直接贴上代码了

import pickle
import numpy as np
from collections import OrderedDict

class SimpleConvNet:
"""简单的ConvNet

conv - relu - pool - affine - relu - affine - softmax

Parameters
----------
input_size : 输入大小(MNIST的情况下为784)
hidden_size_list : 隐藏层的神经元数量的列表(e.g. [100, 100, 100])
output_size : 输出大小(MNIST的情况下为10)
activation : 'relu' or 'sigmoid'
weight_init_std : 指定权重的标准差(e.g. 0.01)
指定'relu'或'he'的情况下设定“He的初始值”
指定'sigmoid'或'xavier'的情况下设定“Xavier的初始值”
"""
def __init__(self, input_dim=(1, 28, 28),
conv_param={'filter_num':30, 'filter_size':5, 'pad':0, 'stride':1},
hidden_size=100, output_size=10, weight_init_std=0.01):
filter_num = conv_param['filter_num']
filter_size = conv_param['filter_size']
filter_pad = conv_param['pad']
filter_stride = conv_param['stride']
input_size = input_dim[1]
conv_output_size = (input_size - filter_size + 2*filter_pad) / filter_stride + 1
pool_output_size = int(filter_num * (conv_output_size/2) * (conv_output_size/2))

# 初始化权重
self.params = {}
self.params['W1'] = weight_init_std * \
np.random.randn(filter_num, input_dim[0], filter_size, filter_size)
self.params['b1'] = np.zeros(filter_num)
self.params['W2'] = weight_init_std * \
np.random.randn(pool_output_size, hidden_size)
self.params['b2'] = np.zeros(hidden_size)
self.params['W3'] = weight_init_std * \
np.random.randn(hidden_size, output_size)
self.params['b3'] = np.zeros(output_size)

# 生成层
self.layers = OrderedDict()
self.layers['Conv1'] = Convolution(self.params['W1'], self.params['b1'],
conv_param['stride'], conv_param['pad'])
self.layers['Relu1'] = Relu()
self.layers['Pool1'] = Pooling(pool_h=2, pool_w=2, stride=2)
self.layers['Affine1'] = Affine(self.params['W2'], self.params['b2'])
self.layers['Relu2'] = Relu()
self.layers['Affine2'] = Affine(self.params['W3'], self.params['b3'])

self.last_layer = SoftmaxWithLoss()

def predict(self, x):
for layer in self.layers.values():
x = layer.forward(x)

return x

def loss(self, x, t):
"""求损失函数
参数x是输入数据、t是标签数据
"""
y = self.predict(x)
return self.last_layer.forward(y, t)

def accuracy(self, x, t, batch_size=100):
if t.ndim != 1 : t = np.argmax(t, axis=1)

acc = 0.0

for i in range(int(x.shape[0] / batch_size)):
tx = x[i*batch_size:(i+1)*batch_size]
tt = t[i*batch_size:(i+1)*batch_size]
y = self.predict(tx)
y = np.argmax(y, axis=1)
acc += np.sum(y == tt)

return acc / x.shape[0]

def numerical_gradient(self, x, t):
"""求梯度(数值微分)

Parameters
----------
x : 输入数据
t : 标签数据

Returns
-------
具有各层的梯度的字典变量
grads['W1']、grads['W2']、...是各层的权重
grads['b1']、grads['b2']、...是各层的偏置
"""
loss_w = lambda w: self.loss(x, t)

grads = {}
for idx in (1, 2, 3):
grads['W' + str(idx)] = numerical_gradient(loss_w, self.params['W' + str(idx)])
grads['b' + str(idx)] = numerical_gradient(loss_w, self.params['b' + str(idx)])

return grads

def gradient(self, x, t):
"""求梯度(误差反向传播法)

Parameters
----------
x : 输入数据
t : 教师标签

Returns
-------
具有各层的梯度的字典变量
grads['W1']、grads['W2']、...是各层的权重
grads['b1']、grads['b2']、...是各层的偏置
"""
# forward
self.loss(x, t)

# backward
dout = 1
dout = self.last_layer.backward(dout)

layers = list(self.layers.values())
layers.reverse()
for layer in layers:
dout = layer.backward(dout)

# 设定
grads = {}
grads['W1'], grads['b1'] = self.layers['Conv1'].dW, self.layers['Conv1'].db
grads['W2'], grads['b2'] = self.layers['Affine1'].dW, self.layers['Affine1'].db
grads['W3'], grads['b3'] = self.layers['Affine2'].dW, self.layers['Affine2'].db

return grads

细心的小伙伴应该已经注意到了在上面代码的注释中提到了He初始值xavier初始值,设置神经网络的初始值也是一门学问,设置一组优秀的初始值可以避免梯度消失的问题,目前比较常见的就是He初始值和xavier初始值,具体该什么网络选取什么初始值还需要多调研调研 。

复杂CNN的实现

上一节中介绍的简单的CNN网络结构在MNIST任务中已经可以取得98%的准确率,可以说十分优秀了,但是加深网络可以获得99%的准确率,当然了并不代表着网络越深,学习的效果越好,接下来实现一个负责的网络,这个网络参考了VGG网络,网络连接比较复杂,这里使用的卷积层全都是 3 × 3 的小型滤波器,特点是随着层的加深,通道数变大(卷积层的通道数从前面的层开始按顺序以 16、16、32、32、64、64 的方式增加),插入池化层,以逐渐减小中间数据的空间大小,并且,后面的全连接层中使用了 Dropout 层,并基于Adam进行最优化。

Dropout 是一种抑制过拟合的方法,在学习的过程中随机删除神经,训练时,随机选出隐藏层的神经元,然后将其删除,被删除的神经元不再进行信号的传递,每传递一次数据,就会随机选择要删除的神经元,然后在测试时,虽然会传递所有的神经元信号,但是对于各个神经元的输出,要乘上训练时的删除比例后再输出,Dropout 的图示如下

实现也分为forward和backward,实现代码如下

class Dropout:
def __init__(self, dropout_ratio=0.5):
self.dropout_ratio = dropout_ratio
self.mask = None

def forward(self, x, train_flg=True):
if train_flg:
self.mask = np.random.rand(*x.shape) > self.dropout_ratio
return x * self.mask
else:
return x * (1.0 - self.dropout_ratio)

def backward(self, dout):
return dout * self.mask

现在来说下Adam,Adam是一种参数更新的最优化方法,结合了Momentum和AdaGrad ,简单来说,Momentum以物理规律来表示梯度的移动,AdaGrad 为参数的每个元素适当地调整更新步伐,这里就直接把代码贴上去,有兴趣的小伙伴可以自行了解

class Adam:

"""Adam (http://arxiv.org/abs/1412.6980v8)"""

def __init__(self, lr=0.001, beta1=0.9, beta2=0.999):
self.lr = lr
self.beta1 = beta1
self.beta2 = beta2
self.iter = 0
self.m = None
self.v = None

def update(self, params, grads):
if self.m is None:
self.m, self.v = {}, {}
for key, val in params.items():
self.m[key] = np.zeros_like(val)
self.v[key] = np.zeros_like(val)

self.iter += 1
lr_t = self.lr * np.sqrt(1.0 - self.beta2**self.iter) / (1.0 - self.beta1**self.iter)

for key in params.keys():
#self.m[key] = self.beta1*self.m[key] + (1-self.beta1)*grads[key]
#self.v[key] = self.beta2*self.v[key] + (1-self.beta2)*(grads[key]**2)
self.m[key] += (1 - self.beta1) * (grads[key] - self.m[key])
self.v[key] += (1 - self.beta2) * (grads[key]**2 - self.v[key])

params[key] -= lr_t * self.m[key] / (np.sqrt(self.v[key]) + 1e-7)

#unbias_m += (1 - self.beta1) * (grads[key] - self.m[key]) # correct bias
#unbisa_b += (1 - self.beta2) * (grads[key]*grads[key] - self.v[key]) # correct bias
#params[key] += self.lr * unbias_m / (np.sqrt(unbisa_b) + 1e-7)

好了好了,返回之前说到的复杂网络,复杂网络可以用下图来表示

这个网络使用 He 初始值作为权重的初始值,使用 Adam 更新权重参数,把上述内容总结起来,这个网络有如下特点

  • 基于 3×3 的小型滤波器的卷积层
  • 激活函数是 ReLU
  • 全连接层的后面使用 Dropout 层
  • 基于 Adam 的最优化
  • 使用 He 初始值作为权重初始值

这个复杂网络的代码如下

import pickle
import numpy as np
from collections import OrderedDict

class DeepConvNet:
"""识别率为99%以上的高精度的ConvNet

网络结构如下所示
conv - relu - conv- relu - pool -
conv - relu - conv- relu - pool -
conv - relu - conv- relu - pool -
affine - relu - dropout - affine - dropout - softmax
"""
def __init__(self, input_dim=(1, 28, 28),
conv_param_1 = {'filter_num':16, 'filter_size':3, 'pad':1, 'stride':1},
conv_param_2 = {'filter_num':16, 'filter_size':3, 'pad':1, 'stride':1},
conv_param_3 = {'filter_num':32, 'filter_size':3, 'pad':1, 'stride':1},
conv_param_4 = {'filter_num':32, 'filter_size':3, 'pad':2, 'stride':1},
conv_param_5 = {'filter_num':64, 'filter_size':3, 'pad':1, 'stride':1},
conv_param_6 = {'filter_num':64, 'filter_size':3, 'pad':1, 'stride':1},
hidden_size=50, output_size=10):
# 初始化权重===========
# 各层的神经元平均与前一层的几个神经元有连接(TODO:自动计算)
pre_node_nums = np.array([1*3*3, 16*3*3, 16*3*3, 32*3*3, 32*3*3, 64*3*3, 64*4*4, hidden_size])
wight_init_scales = np.sqrt(2.0 / pre_node_nums) # 使用ReLU的情况下推荐的初始值

self.params = {}
pre_channel_num = input_dim[0]
for idx, conv_param in enumerate([conv_param_1, conv_param_2, conv_param_3, conv_param_4, conv_param_5, conv_param_6]):
self.params['W' + str(idx+1)] = wight_init_scales[idx] * np.random.randn(conv_param['filter_num'], pre_channel_num, conv_param['filter_size'], conv_param['filter_size'])
self.params['b' + str(idx+1)] = np.zeros(conv_param['filter_num'])
pre_channel_num = conv_param['filter_num']
self.params['W7'] = wight_init_scales[6] * np.random.randn(64*4*4, hidden_size)
self.params['b7'] = np.zeros(hidden_size)
self.params['W8'] = wight_init_scales[7] * np.random.randn(hidden_size, output_size)
self.params['b8'] = np.zeros(output_size)

# 生成层===========
self.layers = []
self.layers.append(Convolution(self.params['W1'], self.params['b1'],
conv_param_1['stride'], conv_param_1['pad']))
self.layers.append(Relu())
self.layers.append(Convolution(self.params['W2'], self.params['b2'],
conv_param_2['stride'], conv_param_2['pad']))
self.layers.append(Relu())
self.layers.append(Pooling(pool_h=2, pool_w=2, stride=2))
self.layers.append(Convolution(self.params['W3'], self.params['b3'],
conv_param_3['stride'], conv_param_3['pad']))
self.layers.append(Relu())
self.layers.append(Convolution(self.params['W4'], self.params['b4'],
conv_param_4['stride'], conv_param_4['pad']))
self.layers.append(Relu())
self.layers.append(Pooling(pool_h=2, pool_w=2, stride=2))
self.layers.append(Convolution(self.params['W5'], self.params['b5'],
conv_param_5['stride'], conv_param_5['pad']))
self.layers.append(Relu())
self.layers.append(Convolution(self.params['W6'], self.params['b6'],
conv_param_6['stride'], conv_param_6['pad']))
self.layers.append(Relu())
self.layers.append(Pooling(pool_h=2, pool_w=2, stride=2))
self.layers.append(Affine(self.params['W7'], self.params['b7']))
self.layers.append(Relu())
self.layers.append(Dropout(0.5))
self.layers.append(Affine(self.params['W8'], self.params['b8']))
self.layers.append(Dropout(0.5))

self.last_layer = SoftmaxWithLoss()

def predict(self, x, train_flg=False):
for layer in self.layers:
if isinstance(layer, Dropout):
x = layer.forward(x, train_flg)
else:
x = layer.forward(x)
return x

def loss(self, x, t):
y = self.predict(x, train_flg=True)
return self.last_layer.forward(y, t)

def accuracy(self, x, t, batch_size=100):
if t.ndim != 1 : t = np.argmax(t, axis=1)

acc = 0.0

for i in range(int(x.shape[0] / batch_size)):
tx = x[i*batch_size:(i+1)*batch_size]
tt = t[i*batch_size:(i+1)*batch_size]
y = self.predict(tx, train_flg=False)
y = np.argmax(y, axis=1)
acc += np.sum(y == tt)

return acc / x.shape[0]

def gradient(self, x, t):
# forward
self.loss(x, t)

# backward
dout = 1
dout = self.last_layer.backward(dout)

tmp_layers = self.layers.copy()
tmp_layers.reverse()
for layer in tmp_layers:
dout = layer.backward(dout)

# 设定
grads = {}
for i, layer_idx in enumerate((0, 2, 5, 7, 10, 12, 15, 18)):
grads['W' + str(i+1)] = self.layers[layer_idx].dW
grads['b' + str(i+1)] = self.layers[layer_idx].db

return grads
Author: Hongyi Guo
Link: https://guohongyi.com/2020/11/12/用Python手撕CNN/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.