CNN简介与实现

CNN简介与实现

导语

CNN全称卷积神经网络,可谓声名远扬,被用于生活中的各个领域,也是最好理解的神经网络结构之一。

整体结构

相较于先前的神经网络,CNN出现了卷积层和池化层的概念,基本的组成模块是"卷积-ReLU-池化",并且,在靠近输出或最后输出时时仍会采用"Affine-ReLU"、"Affine-ReLU"的组合,书上给出的示例图如下:

卷积层

在思考为什么要用卷积层之前,我们可以先来看看卷积层之前的全连接层有什么局限性,全连接层通常要求输入是一个一维的数组,即使原始数据是更高维的数据,如高、长、通道的三维图像,这个时候,使用全连接层,原始数据中的几何信息、点之间的相对位置等空间信息就都被清除了,这些信息其实很重要,因为点与点之间在高维空间的关联性是比一维更强的。

相比之下,卷积层就考虑到了这些空间信息,当输入为图像时,卷积层会以三维数据的形式接受输入数据,并且输出也是三维数据。

CNN中卷积层的输入输出数据被称作特征图,输入叫输入特征图,输出叫输出特征图。

卷积

卷积是卷积层的运算,类似与图像中的滤波器处理,具体做法如图(图源自网络,侵删):

此图省略了卷积核,只给出了输入和结果,以该图为例,输入是一个4×4的矩阵,在矩阵上存在一个3×3的滑动窗口,窗口每次移动一个单位,每次对窗口内的矩阵A进行一次权重累和,具体的权重为同等大小的卷积核矩阵,具体的例子如下, 36 = 1 × 1 + 2 × 1 + 0 × 3 + 4 × 0 + 5 × 2 + 6 × 0 + 7 × 1 + 8 × 2 + 1 × 1 36=1×1+2×1+0×3+4×0+5×2+6×0+7×1+8×2+1×1 36=1×1+2×1+0×3+4×0+5×2+6×0+7×1+8×2+1×1。

与全连接层一样,CNN中也存在偏置,对于算出的结果矩阵,对矩阵中的所有元素可以加上一个相同的偏置值。

填充

在进行卷积前,有时候要把数据拓宽,例如把4×4拓成6×6,如何拓宽呢很简单,把不够的部分都设置为同一个值就可以(一般是0或者1),具体操作如图(图源网络,侵删):

这种做法,就叫做填充,使用填充主要是为了调整输出大小,在使用卷积核运算的时候,如果不进行填充,卷积的结果势必会在整体上变小(如4×4变成2×2),多次使用后,最后的结果就可能只有一个1,因此使用填充来避免这种情况的发生。

步幅

步幅很容易理解,就是滑动窗口的每次的移动距离,像下面这张图,就是步幅为2时候的卷积(图源网络,侵删):

可以看到,增大步幅会使得输出变小,加上填充会变大,这个时候就可以根据两者关系列出卷积输出结果的公式了。

书上的描述如下(值除不尽四舍五入):

三维卷积

在现实使用中,CNN的输入并不是一个单纯的二维矩阵,输入的图像时一个带有高、宽、通道的具体的特征图,以RGB为例,RGB图像是三通道,如果对RGB图像进行卷积,那么就要对图像上的每一个通道都使用一个卷积核,通道方向有多个特征图时,需要按照通道方向进行输入数据和滤波器的卷积运算,并将结果累和,生成一个新的二维矩阵。

立体化

当我们把输入和输出推向更一般的适用情况,多通道输入数据使用对应的多通道核,最后输出一张单个图,书上的例子如下,其中C为通道数、H为高度、W为长度。

如果要再通道方向上也拥有多个卷积运算的输出,就需要使用多个滤波器(权重),书上的图如下:

如果再考虑上偏置,书上给出的图如下:

批处理

通常,为了加快效率,神经网络会将输入数据进行一批批的打包,一次性处理一堆数据,为了处理一批数据,需要在上一张图的基础上加上批次,书上给出的图如下:

数据作为4维数据在各层之间传递,批处理将N次处理汇总成了1次进行。

实现

如果直接实现卷积运算,利用for循环,效率其实是不高的,况且python给出了更好的选择:im2col函数。

im2col将输入数据展开来适合卷积核的计算,书上给出的图如下:

这里更详细的解释一下,输入的是一个三维的数据,把每一面(二维)从左到右,从上到下,拉成一个一维的数组,然后把每个通道的一维数组拼起来,形成一个二维的矩阵,如果是多批次,就把这些矩阵首尾相连,形成一个更大的二维矩阵即可。

实际的卷积运算中,卷积核的应用区域几乎彼此重叠,因此,在使用im2col之后,展开的元素个数会多于原来的输入元素个数,所以会消耗更多的内存。

书上给出了用im2col进行卷积的流程:

还需要明晰的一点是,im2col的使用并不会损失原数据在空间上的信息,它只是为了方便进行矩阵对数据进行了一些处理,并且在最后恢复了原来的数据模式。

书上给出了im2col和基于im2col实现的卷积层代码如下:

python 复制代码
def im2col(input_data, filter_h, filter_w, stride=1, pad=0):
    #输入,高,长,步幅,填充
    N, C, H, W = input_data.shape
    out_h = (H + 2*pad - filter_h)//stride + 1#根据步长和高度计算输出的长高
    out_w = (W + 2*pad - filter_w)//stride + 1

    img = np.pad(input_data, [(0,0), (0,0), (pad, pad), (pad, pad)], 'constant')
    col = np.zeros((N, C, filter_h, filter_w, out_h, out_w))#设置一个空的拉伸之后的二维数组

    for y in range(filter_h):
        y_max = y + stride*out_h
        for x in range(filter_w):
            x_max = x + stride*out_w
            col[:, :, y, x, :, :] = img[:, :, y:y_max:stride, x:x_max:stride]

    col = col.transpose(0, 4, 5, 1, 2, 3).reshape(N*out_h*out_w, -1)

    return col

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)#拉伸
		#卷积层反向传播的时候,需要进行im2col的逆处理
		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)
		#更改轴的顺序,NHWC变成NCHW
		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

池化层

简单来说,卷积是使用卷积核计算对应区域的乘积和,池化层是选取对应区域的最大值(也有其他的池化,比如平均值池化,指的是取对应区域的平均值作为输出),书上给出的例子如下:

特点

池化层的操作很简单,不需要像卷积层那样学习卷积核的参数,只需要提取最值或平均即可;其次,池化层的计算是按照通道独立进行的,输入和输出的通道数不会变化;最后,池化层对输入数据的微小偏差具有鲁棒性(例如目标区域的非最大值有变化,并不会影响池化层最后的输出)。

实现

池化层也是用im2col展开,但展开时在通道方向上是独立的,书上给的图示如下:

书上的实现代码如下:

python 复制代码
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的具体代码实现,具体图如下:

书上加上注释的代码如下:

python 复制代码
class SimpleConvNet:
    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):#计算损失
        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):#求梯度,用数值微分方法
        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):#误差反向传播求梯度
        # 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
        

训练所需要的时间相较于先前的方法比较久,但是得到的结果识别率更高,具体训练结果如下:

可视化

"卷积"是一种数学运算,逻辑上其实很难理解到它的用处,因此,书上给出了对卷积作用更加直接的展现方式,以上一部分学习前和学习后的卷积核为例,各个卷积核的权重图如下:

学习前:

学习后:

可以明显的看到,学习前杂乱无章的权重矩阵,在学习后变得有迹可循,明显有些区域的权重更深一些,那么,这些权重更大的部分对应的目标究竟是什么呢?

书上给出了答案:这些卷积核在学习边缘(颜色变化的分界线)和斑块(局部的块状区域),例如黑白分界线,可以根据手写数字识别的例子想象,手写的数字是黑色,背景是白色,那么卷积核的目标就是使得模型对黑色的部分更加敏感,权重更大。

上述的结果是只进行了一次卷积得到的,随着层次的加深,提取的信息也会越来越抽象,在深度学习中,最开始层会对简单的边缘有响应,接下来是对纹理,在接下来是对更复杂的性质,随着层次递增,模型的目标会从简单的形状进化到更高级的信息。

总结

本章详细介绍了CNN的构造,对卷积层、池化层进行了从零开始的实现,但是对反向传播的部分只给出了代码实现。最重要的还是对im2col的理解,明白了im2col的原理,卷积层、池化层乃至反向传播的实现,这些问题就迎刃而解了。

基于最基本的CNN,后续还有更多功能强大,网络结构更深的CNN网络,如LeNet(激活函数为sigmod,使用子采样缩小中间数据大小,而不是卷积、池化)还有AlexNet(多个卷积层和池化层,激活函数为sigmod,使用进行局部正规化的LRN层,使用Dropout)等。

参考文献

  1. 【Pytorch实现】------深入理解im2col(详细图解)
  2. 12张动图帮你看懂卷积神经网络到底是什么
  3. 《深度学习入门------基于Python的理论与实现》
相关推荐
陈广亮10 小时前
构建具有长期记忆的 AI Agent:从设计模式到生产实践
人工智能
会写代码的柯基犬10 小时前
DeepSeek vs Kimi vs Qwen —— AI 生成俄罗斯方块代码效果横评
人工智能·llm
Mintopia11 小时前
OpenClaw 是什么?为什么节后热度如此之高?
人工智能
爱可生开源社区11 小时前
DBA 的未来?八位行业先锋的年度圆桌讨论
人工智能·dba
叁两14 小时前
用opencode打造全自动公众号写作流水线,AI 代笔太香了!
前端·人工智能·agent
敏编程14 小时前
一天一个Python库:jsonschema - JSON 数据验证利器
python
前端付豪14 小时前
LangChain记忆:通过Memory记住上次的对话细节
人工智能·python·langchain
strayCat2325514 小时前
Clawdbot 源码解读 7: 扩展机制
人工智能·开源
程序员打怪兽14 小时前
详解Visual Transformer (ViT)网络模型
深度学习