Pytorch使用

卷积池化与CNN

Posted by Welt Xing on September 7, 2021

引言

在用Pytorch构建神经网络时,全连接网络的构建很简单:

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.fc1 = nn.Linear(100, 512)
        self.fc2 = nn.Linear(512, 10)

    def forward(self, x):
        x = self.fc1(x)
        x = F.relu(x)
        x = self.fc2(x)
        return F.log_softmax(x, dim=1)

只需要上一层的输出神经元数等于下一层的输入神经元数即可。但一旦到了卷积神经网络,卷积和池化操作会带来数据尺寸的变化,对于初学者(比如我)来说,往往不是很友好,下面是识别MNIST数据集的CNN:

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1 = nn.Conv2d(1, 20, 5, 1)
        self.pool1 = nn.MaxPool2d(2, 2)
        self.conv2 = nn.Conv2d(20, 50, 5, 1)
        self.pool2 = nn.MaxPool2d(2, 2)
        self.fc1 = nn.Linear(4 * 4 * 50, 500)
        self.fc2 = nn.Linear(500, 10)

    def forward(self, x):
        x = F.relu(self.conv1(x))
        x = self.pool1(x)
        x = F.relu(self.conv2(x))
        x = self.pool2(x)
        x = x.view(-1, 4 * 4 * 50)
        x = F.relu(self.fc1(x))
        x = self.fc2(x)
        return F.log_softmax(x, dim=1)

其中的参数对于初学者来说是confusing的,我们这里想把这些参数的含义和设置方法摸清,为后面的工作带来方便。

前置文章:Understanding of a Convolutional Neural Network - 邢存远的博客

卷积池化函数详解

卷积

torch.nn中有三种卷积算子:Conv1dConv2dConv3d,分别用来处理一维、二维和三维数据,二维对应图片,三维对应视频;一维卷积的话,笔者在Kaggle中看到过有人用一维卷积来进行数据降维。

Conv1d

直接看参数:

  • in_channels:输入通道数;
  • out_channels:输出通道数;
  • kernel_size:卷积核大小;
  • stride=1:滑动步长;
  • padding=0:边缘填充的单元数。

不常用的参数这里忽略不谈。我们来看一个合法的一维卷积运算:

import torch

m = torch.nn.Conv1d(
    in_channels=16, 
    out_channels=33, 
    kernel_size=3, 
    stride=2,
)
i = torch.randn(20, 16, 50)
o = m(i)
print(o.shape) # (20, 33, 24)

这里的i是一个20个样本(batch_size)的数据,对应i的第一维是20;输入通道数为16(输入通道数类比数据的层数,RGB图片的输入通道数就是3),对应i的第二维是16;每个通道就是一个长度为50的向量,对一个i的第三维是50。

当我们用一个卷积核对数据i中的一个样本,比如i[0]进行卷积,考虑多个通道,卷积生成的是一个长度为

\[1+\bigg\lfloor\dfrac{50-3}{2}\bigg\rfloor=24\]

的向量。而我们又规定了输出通道数为33,对应使用33个卷积核进行卷积,因此输出是(20, 33, 24)的张量。

网上资料(包括论文)常常将卷积核定义为一个普通矩阵,比如锐化卷积核就是

\[\begin{bmatrix} 0&-1&0\\ -1&5&-1\\ 0&-1&0 \end{bmatrix}\]

对图像进行处理后,图像中的边缘会加粗(详情参考卷积与池化的探究 - 邢存远的博客 Welt Xing’s Blog (welts.xyz))。但这样其实忽略了RGB图片是有3个通道的,我们这里的锐化操作,其实是对每个通道(对应一个大矩阵)用上面的矩阵进行卷积操作,然后将各个通道卷积的结果合并成一个新的RGB图片(关于RGB图片相关可参考Pillow - 邢存远的博客 Welt Xing’s Blog (welts.xyz))。从这种意义上说,真正的锐化卷积核是一个三维张量(高阶张量参考高阶张量与Pytorch - 邢存远的博客 Welt Xing’s Blog (welts.xyz)):

\[\begin{bmatrix} \begin{bmatrix} 0&-1&0\\ -1&5&-1\\ 0&-1&0 \end{bmatrix}&\begin{bmatrix} 0&-1&0\\ -1&5&-1\\ 0&-1&0 \end{bmatrix}& \begin{bmatrix} 0&-1&0\\ -1&5&-1\\ 0&-1&0 \end{bmatrix} \end{bmatrix}\]

形象化地说就是一个3*3*3的立方体,立方体的每一层都是相同的矩阵。

在这里,我们的样例是16个通道,卷积核长度为3,考虑通道数,这里的卷积核是16*3的矩阵,16个通道各自卷积出来的结果被合并成一个通道,也就是输出通道,而当我们采用了$n$个不同的卷积核,那么就会有$n$个输出通道,参数out_channels就对应的这个$n$.

Conv2d

Conv2d,也就是二维卷积,参数和上面是一样的,但由于是2个维度的卷积,不同维度的kernel_sizestridepadding都可以视需要而不同,比如下面两种卷积层都是可行的:

# With square kernels and equal stride
m = nn.Conv2d(16, 33, 3, stride=2)
# non-square kernels and unequal stride and with different padding
m = nn.Conv2d(16, 33, (3, 5), stride=(2, 1), padding=(4, 2))

类比前面的一维卷积中,每个通道中是一个向量,那么二维卷积下每个通道就是一个矩阵,可以类比三通道的RGB图片。一个合法的二维卷积运算:

import torch

m = torch.nn.Conv2d(16, 33, 3, stride=2)
i = torch.randn(20, 16, 50, 100)
o = m(i)
print(o.shape) # (20, 33, 24, 49)

对于o的尺寸,20和33很好解释,24和49分别由卷积尺寸的计算公式获得:

\[1+\bigg\lfloor\dfrac{50-3}{2}\bigg\rfloor=24,1+\bigg\lfloor\dfrac{100-3}{2}\bigg\rfloor=49\]

Conv3d

三维卷积主要是应用于视频数据,多出来一个表征时间的帧维度,三维卷积层的定义类比上面,例如:

m = nn.Conv3d(16, 33, (3, 5, 2), stride=(2, 1, 1), padding=(4, 2, 0))

一个合法是三维卷积运算:

import torch

m = torch.nn.Conv3d(16, 33, 3, stride=2)
i = torch.randn(20, 16, 10, 50, 100)
o = m(i)
print(o.shape) # (20, 33, 4, 24, 49)

这里的输入数据,每个通道中是一个10帧的画面,每一帧都是一个50*100的二维数据(图像)。

池化

池化往往出现在卷积层的后面,用于对数据进行降维,分为平均池化和最大池化;池化同样可以分为一维、二维和三维池化,对应的,Pytorch中有6中不同的池化层函数:MaxPool1d, AvgPool1d, MaxPool2d, AvgPool2d, MaxPool3d和AvgPool3d. 为简单起见,我们在这里只讨论最大池化的函数。

MaxPool1d

池化的参数比卷积要少,因为池化操作是在每个通道独立进行的,所以通道数是不变的:

  • kernel_size:单次池化操作的范围大小;
  • stride=1:滑动步长;
  • padding=0:填充数.

一个合法的卷积操作:

import torch

m = torch.nn.MaxPool1d(3, stride=2)
i = torch.randn(20, 16, 50)
o = m(i)
print(o.shape) # (20, 16, 24)

这里通道数不变,只是每个通道的向量长度发生了变化,变化遵从公式

\[O=1+\bigg\lfloor\dfrac{N-F+2P}{S}\bigg\rfloor\]

MaxPool2d和MaxPool3d

至于二维池化和三维池化,我们可以类比上面的一维池化和前面的卷积方法,在此不过多赘述。

网络实例

我们回顾前面提到的手写数字识别的卷积神经网络:

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1 = nn.Conv2d(1, 20, 5, 1)
        self.pool1 = nn.MaxPool2d(2, 2)
        self.conv2 = nn.Conv2d(20, 50, 5, 1)
        self.pool2 = nn.MaxPool2d(2, 2)
        self.fc1 = nn.Linear(4 * 4 * 50, 500)
        self.fc2 = nn.Linear(500, 10)

    def forward(self, x):
        x = F.relu(self.conv1(x))
        x = self.pool1(x)
        x = F.relu(self.conv2(x))
        x = self.pool2(x)
        x = x.view(-1, 4 * 4 * 50)
        x = F.relu(self.fc1(x))
        x = self.fc2(x)
        return F.log_softmax(x, dim=1)

逐层分析,同时计算输入输出各层数据的尺寸:

  1. 卷积核为5*5,1通道输入,20通道输出的卷积层并通过ReLU函数激活(1*28*28→20*24*24);
  2. 池化核为2*2,滑动步长为2的池化层,数据的通道数保持20不变(20*24*24→20*12*12);
  3. 卷积核为5*5,20通道输入,50通道输出的卷积层,通过ReLU函数激活(20*12*12→50*8*8);
  4. 池化核为2*2,滑动步长为2的池化层,数据的通道数保持50不变(50*8*8→50*4*4);
  5. 将数据展平,并送入全连接层(50*4*4→500);
  6. 以10输出的Softmax层作为输出层输出(500→10).