神经网络卷积层与池化层

Posted 深度不学习。

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了神经网络卷积层与池化层相关的知识,希望对你有一定的参考价值。

学习了李沐——《动手学深度学习》的视频课程,在此对知识点进行整理以及记录动手实践中遇到的一些问题和想法。

        首先,我们导入可能用到的包

import torch
from torch import nn

一.卷积层

        关于卷积层的概念,笔者在此不再过多叙述,总之,卷积层的作用是提取输入图片中的信息,这些信息被称为图像特征,这些特征是由图像中的每个像素通过组合或者独立的方式所体现,比如图片的纹理特征,颜色特征。

1.卷积层运算

        在神经网络中进行卷积操作可以看作是互相关运算,具体指的是输入张量与核张量之间进行互相关操作产生输出张量。在书中提到的卷积操作的理论描述中,可以总结为卷积核窗口按照设定好的步幅,对输入张量进行按元素做乘法后求和的操作。

        

         上图中的操作,可以由下面的数学运算表示:

        

         由于卷积核的宽度和高度都大于1,所以经过卷积操作的输出张量应该是小于(或者等于)输入张量的大小。我们分别将输出张量的高和宽由输入大小X,卷积核的高和宽记为X记为下式表示:

        

         卷积运算的原理可有以下代码实现:

def corr2d(X, K):  
    """计算二维互相关运算"""
    h, w = K.shape
    Y = torch.zeros((X.shape[0] - h + 1, X.shape[1] - w + 1))
    for i in range(Y.shape[0]):
        for j in range(Y.shape[1]):
            Y[i, j] = (X[i:i + h, j:j + w] * K).sum()
    return Y

        接下来,我们用上述代码验算上图中的卷积运算:

X = torch.tensor([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]])
K = torch.tensor([[0.0, 1.0], [2.0, 3.0]])
corr2d(X, K)
tensor([[19., 25.],
        [37., 43.]])

2.神经网络卷积层

        卷积层对输入和卷积标量偏置。核权重进行互相关运算,并在添加标量偏置之后产生输出。所以,在卷积层中,我们要训练的参数是卷积核的权重和其标量偏置。

class Conv2D(nn.Module):
    def __init__(self, kernel_size):
        super().__init__()
        self.weight = nn.Parameter(torch.rand(kernel_size))
        self.bias = nn.Parameter(torch.zeros(1))

    def forward(self, x):
        return corr2d(x, self.weight) + self.bias

        上述代码中,笔者遇到的问题是nn.Paramater()这个方法。该方法是将一个固定的张量转变成一个可以训练的paramater,并且将这个参数绑定到模型中。笔者试过不用这个方法,直接用torch.full()和torch.zeros()初始化两个参数,最后并不能用state_dict()这个方法得到预设的参数。

3.图像中目标的边缘检测

        书中讲述了一个检测黑白色垂直边缘的方法。在一张通道数为1的图片中,0和1分别表示黑色和白色。

X = torch.ones((6, 8))
X[:, 2:6] = 0
X
tensor([[1., 1., 0., 0., 0., 0., 1., 1.],
        [1., 1., 0., 0., 0., 0., 1., 1.],
        [1., 1., 0., 0., 0., 0., 1., 1.],
        [1., 1., 0., 0., 0., 0., 1., 1.],
        [1., 1., 0., 0., 0., 0., 1., 1.],
        [1., 1., 0., 0., 0., 0., 1., 1.]])

        在这个张量中,前两列和最后两列为白色,中间为黑色,我们的目的是要检测出由1变为0以及由0变为1的边界,并分别记为1和-1。

        接下来,我们要构造一个1X2的卷积核,并通过这个卷积核检测出黑白边缘。至于这个卷积核为什么这样来创建,沐导说的是,这个核是通过学习所获得的。

K = torch.tensor([[1.0, -1.0]])

        接下来,我们用最开始创建的方法进行边缘检测。

Y = corr2d(X, K)
Y
tensor([[ 0.,  1.,  0.,  0.,  0., -1.,  0.],
        [ 0.,  1.,  0.,  0.,  0., -1.,  0.],
        [ 0.,  1.,  0.,  0.,  0., -1.,  0.],
        [ 0.,  1.,  0.,  0.,  0., -1.,  0.],
        [ 0.,  1.,  0.,  0.,  0., -1.,  0.],
        [ 0.,  1.,  0.,  0.,  0., -1.,  0.]])

 4.学习卷积核

        接下来,我们来实现如何学的上述边缘检测中的卷积核。这个过程的主要思想和神经网络的学习类似,在网络中进行训练,得到输出,之后计算模型输出与原数据的损失,再经过梯度下降更新参数,经过若干轮次的迭代,我们便可以求得参数weight(这里我们将偏置忽略掉)。
        

# 构造一个二维卷积层,它具有1个输出通道和形状为(1,2)的卷积核
conv2d = nn.Conv2d(1,1, kernel_size=(1, 2), bias=False)

# 这个二维卷积层使用四维输入和输出格式(批量大小、通道、高度、宽度),
# 其中批量大小和通道数都为1
X = X.reshape((1, 1, 6, 8))
Y = Y.reshape((1, 1, 6, 7))
lr = 3e-2  # 学习率

for i in range(10):
    Y_hat = conv2d(X)
    l = (Y_hat - Y) ** 2
    conv2d.zero_grad()
    l.sum().backward()
    # 迭代卷积核
    conv2d.weight.data[:] -= lr * conv2d.weight.grad
    if (i + 1) % 2 == 0:
        print(f'epoch i+1, loss l.sum():.3f')
epoch 2, loss 6.567
epoch 4, loss 2.060
epoch 6, loss 0.738
epoch 8, loss 0.285
epoch 10, loss 0.114

        在经过10次的迭代之后,误差便降到足够低,现在我们来看一看我们所学习到的权重张量。

conv2d.weight.data.reshape((1, 2))
tensor([[ 0.9571, -1.0261]])

5.突发奇想

        上文中提到,现有的卷积核只能检测垂直边缘,笔者想将检测垂直边缘的卷积核学习出来。在这里,我们创建一个8X8的张量,将前两行和最后两行标记为白色1,其他位置置0。卷积核的大小设计为2X2。我们最后得到的输出应给是一个7X7的张量。

X = torch.ones((8,8))
X[2:6, :] = 0
Y = torch.zeros(7, 7)
Y[1, :] = 1
Y[-2, :] = -1
print(X)
print(Y)
tensor([[1., 1., 1., 1., 1., 1., 1., 1.],
        [1., 1., 1., 1., 1., 1., 1., 1.],
        [0., 0., 0., 0., 0., 0., 0., 0.],
        [0., 0., 0., 0., 0., 0., 0., 0.],
        [0., 0., 0., 0., 0., 0., 0., 0.],
        [0., 0., 0., 0., 0., 0., 0., 0.],
        [1., 1., 1., 1., 1., 1., 1., 1.],
        [1., 1., 1., 1., 1., 1., 1., 1.]])
tensor([[ 0.,  0.,  0.,  0.,  0.,  0.,  0.],
        [ 1.,  1.,  1.,  1.,  1.,  1.,  1.],
        [ 0.,  0.,  0.,  0.,  0.,  0.,  0.],
        [ 0.,  0.,  0.,  0.,  0.,  0.,  0.],
        [ 0.,  0.,  0.,  0.,  0.,  0.,  0.],
        [-1., -1., -1., -1., -1., -1., -1.],
        [ 0.,  0.,  0.,  0.,  0.,  0.,  0.]])

        具体训练过程如下所示:

c_net = nn.Conv2d(1, 1, kernel_size=2, bias=False)
X = X.reshape((1, 1, 8, 8))
Y = Y.reshape((1, 1, 7, 7))

lr = 0.01
for i in range(10):
    Y_hat = c_net(X)
    l = (Y_hat - Y) ** 2
    c_net.zero_grad()
    l.sum().backward()
    
    c_net.weight.data[:] -= lr * c_net.weight.grad
    if (i + 1) % 2 == 0:
        print(f'epoch i+1, loss l.sum():.3f')

print(c_net.weight.data)
epoch 2, loss 3.257
epoch 4, loss 0.770
epoch 6, loss 0.204
epoch 8, loss 0.055
epoch 10, loss 0.015
tensor([[[[ 0.8568,  0.1198],
          [-0.2884, -0.6882]]]])

        可以看到,在经过十轮的训练之后,loss已经非常低,而且我们成功学习到了卷积核中的参数weight。我们用这个卷积核中的数据,放到本文最开始我们创建的卷积层中去进行验证。

K = c_net.weight.data.reshape(2,2)
y_hat = corr2d((X.reshape(8, 8), K)
print(y_hat.data)
tensor([[-4.1306e-05, -4.1306e-05, -4.1306e-05, -4.1306e-05, -4.1306e-05,
         -4.1306e-05, -4.1306e-05],
        [ 9.7663e-01,  9.7663e-01,  9.7663e-01,  9.7663e-01,  9.7663e-01,
          9.7663e-01,  9.7663e-01],
        [ 0.0000e+00,  0.0000e+00,  0.0000e+00,  0.0000e+00,  0.0000e+00,
          0.0000e+00,  0.0000e+00],
        [ 0.0000e+00,  0.0000e+00,  0.0000e+00,  0.0000e+00,  0.0000e+00,
          0.0000e+00,  0.0000e+00],
        [ 0.0000e+00,  0.0000e+00,  0.0000e+00,  0.0000e+00,  0.0000e+00,
          0.0000e+00,  0.0000e+00],
        [-9.7667e-01, -9.7667e-01, -9.7667e-01, -9.7667e-01, -9.7667e-01,
         -9.7667e-01, -9.7667e-01],
        [-4.1306e-05, -4.1306e-05, -4.1306e-05, -4.1306e-05, -4.1306e-05,
         -4.1306e-05, -4.1306e-05]])

        可以看到,虽然我们没有真正得到一个只有1、0、-1三个数据组成的张量,但第一行和最后一行的数据太小,我们可以近似看成0,第二行和倒数第二行也可以近似看成1和-1。由此,我们成功学习到了我们所要求的卷积核。

6.填充

        在使用多层卷积时,我们经常丢失边缘像素。由于我们通常使用小卷积核,因此对于任何单个卷积,我们可能只会丢失几个像素。 但随着我们应用许多连续卷积层,累积丢失的像素数就多了。 解决这个问题的简单方法即为填充(padding):在输入图像的边界填充元素(通常填充元素是0)。

         通常,如果我们添加行填充(大约一半在顶部,一半在底部)和列填充(左侧大约一半,右侧一半),则输出形状将为

         在许多情况下,我们需要设置,这样,我们就能让输入张量和输出张量具有相同的高度和宽度,以便我们可以更好的去预测每个图层的输出形状。假设是奇数,我们将在高度的两侧填充行。 如果是偶数,则一种可能性是在输入顶部填充⌈𝑝ℎ/2⌉行,在底部填充⌊𝑝ℎ/2⌋行。同理,我们填充宽度的两侧。

        我们通常将神经网络中卷积核的高度和宽度设置为奇数,这样我们就可以在保持空间维度的同时,可以在顶部和底部填充相同数量的行,在左侧和右侧填充相同数量的列。

conv2d = nn.Conv2d(1, 1, kernel_size=3, padding=1)
X = torch.rand(8, 8)
y = conv2d(X.reshape((1, 1)+X.shape))
y.shape[2:]
torch.Size([8, 8])

        总结一下,如果我们想要得到与输入张量相同尺寸的输出张量,那么我们可以将卷积核的高和宽设定为奇数,然后将padding设置成卷积核尺寸减1的一半即可。

7.步幅

        在之前所有的例子中,卷积窗口都是从输入张量的左上角开始,向下、向右滑动,而且每次只滑动一个元素。但是,有时候为了高效计算或是缩减采样次数,卷积窗口可以跳过中间位置,每次滑动多个元素。

        卷积核每次滑动元素的数量称为步幅(stride)。这个超参数的默认数值为1,当我们舍此那个垂直步幅为,水平步幅为时,输出张量的形状就变为

        如果我们设置了 ,输出形状可以简化为

        

         当输入的高度和宽度都可以被垂直和水平步幅整除时,输出形状就为。例如下面,我们将将8X8的张量输入进卷积核为3X3,padiing为1,stride为2的卷积层中,就可以得到4X4的输出张量。

conv2d = nn.Conv2d(1, 1, kernel_size=3, padding=1, stride=2)
y = conv2d(X.reshape(1, 1, 8,8))
y.shape[2:]
torch.Size([4, 4])

8.通道数

        通道数也是卷积层中的一个重要超参数,它分为输入通道数和输出通道数。上文中我们一直用通道数为1的张量做为输入和输出。如果我们将一张彩色图片输入到卷积网络中,此时,输入通道数通常为3(R,G,B)。

(1).多输入通道

        如下所示,我们实现一个计算多输入通道的卷积函数。

def corr2d_multi_in(X, K):
    # 先遍历“X”和“K”的第0个维度(通道维度),再把它们加在一起
    return sum(d2l.corr2d(x, k) for x, k in zip(X, K))

        接下来,我们创建一个通道数为2的输入张量和卷积核。

X = torch.tensor([[[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]],
               [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]])
K = torch.tensor([[[0.0, 1.0], [2.0, 3.0]], [[1.0, 2.0], [3.0, 4.0]]])

corr2d_multi_in(X, K)
tensor([[ 56.,  72.],
        [104., 120.]])

        最后,我们得到了输出通道为1的输出张量。上述代码我们实现了如下图所示的计算。

 (2).多输出通道

        同样,我们也可以得到多个通道数的输出。在最流行的神经网络架构中,随着神经网络层数的加深,我们常会增加输出通道的维数,通过减少空间分辨率以获得更大的通道深度。直观地说,我们可以将每个通道看作对不同特征的响应。而现实可能更为复杂一些,因为每个通道不是独立学习的,而是为了共同使用而优化的。因此,多输出通道并不仅是学习多个单通道的检测器。

        在进行多输入通道,单输出通道的卷积时,我们所设计的卷积核的尺寸为,当我们想得到多输出通道的输出张量时,便要设计尺寸为的卷积核。

        下面代码,我们实现一个计算多通道输出的卷积函数。

def corr2d_multi_in_out(X, K):
    # 迭代“K”的第0个维度,每次都对输入“X”执行互相关运算。
    # 最后将所有结果都叠加在一起
    return torch.stack([corr2d_multi_in(X, k) for k in K], 0)

        接下来,我们构造一个具有3个输出通道,并接受2个输入通道的卷积核。

K = torch.stack((K, K + 1, K + 2), 0)
K.shape
torch.Size([3, 2, 2, 2])

        下面,我们让输入张量X,与卷积核K进行互相关计算。

corr2d_multi_in_out(X, K)
tensor([[[ 56.,  72.],
         [104., 120.]],

        [[ 76., 100.],
         [148., 172.]],

        [[ 96., 128.],
         [192., 224.]]])

        可以看到,我们得到了一个3通道的输出张量。

(3).1X1卷积层

        1X1卷积经常包含在复杂深层的网络设计中。因为使用了最小窗口,1×1卷积失去了卷积层的特有能力——在高度和宽度维度上,识别相邻元素间相互作用的能力。 其实1×1卷积的唯一计算发生在通道上。1X1卷积层通常用于调整网络层的通道数量和控制模型复杂性。

        下图展示了1X1卷积核与3个输入通道和2个输出通道的卷积计算。这里输入和输出具有相同的高度和宽度,输出中的每个元素都是从输入图像中同一位置的线性组合。我们可以将1X1卷积层看作是在每个像素位置应用的全连接层,这个全连接层接受个输入,并产生个输出。

 二.池化层

        卷积层具有两个目的:降低卷积层对未知的敏感性,同时降低对空间采样表示的敏感性。

1.最大池化层和平均池化层

        池化层与卷积层十分类似,都有一个固定尺寸的窗口(核),该窗口按照设定的步幅大小在输入张量上滑动。与卷积层不同的是,池化层不会对输入张量进行互相关操作,池化核也不包含参数。池化运算通常是固定的,菲苾是最大池化与平均池化。

        最大池化就是找出被池化窗口所包含的输入张量中,最大的数值。而平均池化是计算被池化窗口所包含的输入张量所有数值的平均数。

def pool2d(X, pool_size, mode='max'):
    p_h, p_w = pool_size
    Y = torch.zeros((X.shape[0] - p_h + 1, X.shape[1] - p_w + 1))
    for i in range(Y.shape[0]):
        for j in range(Y.shape[1]):
            if mode == 'max':
                Y[i, j] = X[i: i + p_h, j: j + p_w].max()
            elif mode == 'avg':
                Y[i, j] = X[i: i + p_h, j: j + p_w].mean()
    return Y

        我们构建图片中的输入张量,并创建2X2的池化核。

X = torch.tensor([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]])
pool2d(X, (2, 2))
tensor([[4., 5.],
        [7., 8.]])

        此外,我们还可以验证以下平均池化层。

pool2d(X, (2, 2), 'avg')
tensor([[2., 3.],
        [5., 6.]])

2.填充和步幅

        与卷积层类似,池化层也有填充padding以及步幅stride。默认情况下,深度学习框架中池化层的步幅大小与池化窗口的大小相同。

X = torch.arange(16, dtype=torch.float32).reshape((1, 1, 4, 4))
pool2d = nn.MaxPool2d(3)
pool2d(X)
tensor([[[[10.]]]])

        同样,填充和步幅都可以手动设定。

        

pool2d = nn.MaxPool2d(3, padding=1, stride=2)
pool2d(X)
tensor([[[[ 5.,  7.],
          [13., 15.]]]])

3.多个通道

        在处理多个通道时,池化层在每个输入通道上进行单独运算,而不是像卷积层一样在通道上对输入进行汇总。这就意味着,在池化层中,输出通道数与输入通道数是相同的。

X = torch.cat((X, X + 1), 1)
pool2d = nn.MaxPool2d(3, padding=1, stride=2)
pool2d(X)
tensor([[[[ 5.,  7.],
          [13., 15.]],

         [[ 6.,  8.],
          [14., 16.]]]])

三.总结实验

        接下来,笔者写了一段代码,将一张3通道的输入图片,转化成了1通道的图片。在此代码中,运用了卷积层和池化层。

from torch import nn
from PIL import Image
from torch.utils.tensorboard import SummaryWriter
from torchvision.transforms import transforms

writer = SummaryWriter('./log_demo')

img_path = 'demo.jpg'
img = Image.open(img_path)

trans = transforms.ToTensor()
img_tensor = trans(img)

net = nn.Sequential(
    nn.Conv2d(in_channels=3, out_channels=1, kernel_size=3, padding=1),
    nn.MaxPool2d(3, stride=1)
)

img_out = net(img_tensor)

writer.add_image('img1', img_tensor.reshape(3, 430, 521))
writer.add_images('img2', img_out.reshape(-1, 1, 428, 519))

writer.close()

        我们来看一下效果。

输入

输出

以上是关于神经网络卷积层与池化层的主要内容,如果未能解决你的问题,请参考以下文章

深度学习

卷积降维与池化降维的对比分析

卷积神经网络(CNN)

卷积与池化

CNN 卷积神经网络 池化层Pooling 动手学深度学习v2 pytorch

FPGA教程案例56深度学习案例3——基于FPGA的CNN卷积神经网络之池化层verilog实现