卷积神经网络-从认识CNN到图像处理实战
对于图像的预测,我们先前使用MLP构建具有多个隐藏层的神经网络来提取图像的深度特征,对于比较复杂的图像,通过扩展更多的神经元或者增加隐藏层的数量来提取图像的特征。但是当我们的神经元特别多,隐藏层数量特别多的时候,整个网络的参数量就会爆炸式的增长:如上图所属,当单层的MLP具有100神经元的时候,针对3.6MB特征的图片,会产生约14GB的参数量。为了降低模型的复杂度,同时也是为了更好的学习到图像的
卷积神经网络(CNN)概述
从MLP到卷积
对于图像的预测,我们先前使用MLP构建具有多个隐藏层的神经网络来提取图像的深度特征,对于比较复杂的图像,通过扩展更多的神经元或者增加隐藏层的数量来提取图像的特征。
但是当我们的神经元特别多,隐藏层数量特别多的时候,整个网络的参数量就会爆炸式的增长:
如上图所属,当单层的MLP具有100神经元的时候,针对3.6MB特征的图片,会产生约14GB的参数量。
为了降低模型的复杂度,同时也是为了更好的学习到图像的特征,更好的关注图像中的全局和局部的信息,我们使用卷积神经网络来对图像进行特征提取。
相对于MLP,CNN具有如下优势:
- 参数共享:在卷积层中,同一个卷积核会在图像的不同位置重复应用,这样可以极大地减少参数的数量。例如,一个3x3的卷积核在整个图像上滑动时,只会有9个权重参数需要学习,而不是像MLP那样每个像素点都需要独立的权重。
- 局部感受野:卷积神经网络通过局部感受野来捕捉图像的细节特征,这有助于模型更好地理解图像中的局部结构。
- 层次结构:CNN通常包含多个层次,每一层都会提取更高层次的特征。这种层次结构使得CNN能够从简单的边缘检测到复杂的形状和对象识别逐步构建特征表示。
- 池化层:池化层可以进一步减少特征图的空间维度,同时保留最重要的信息,这有助于减少计算量并防止过拟合。
- 平移不变性:由于卷积操作的平移不变性,CNN对于输入图像的小幅度平移具有一定的鲁棒性。
- 稀疏连接:与MLP中的每个神经元都与前一层的所有神经元相连不同,CNN中的神经元只与其相邻的神经元相连,这减少了连接的数量,从而减少了参数量。
关于卷积
卷积的概念最初来自于数学中的卷积函数:
(f∗g)(x)=∫f(z)g(x−z)dz. (f * g)(\mathbf{x}) = \int f(\mathbf{z}) g(\mathbf{x}-\mathbf{z}) d\mathbf{z}. (f∗g)(x)=∫f(z)g(x−z)dz.
也就是说,卷积是当把一个函数“翻转”并移位x时,测量f和g之间的重叠。 当为离散对象时,积分就变成求和。例如,对于由索引为Z的、平方可和的、无限维向量集合中抽取的向量,我们得到以下定义:
(f∗g)(i)=∑af(a)g(i−a). (f * g)(i) = \sum_a f(a) g(i-a). (f∗g)(i)=a∑f(a)g(i−a).
实际上在深度学习中的卷积与传统数学意义上的卷积具有比较大的差距,一般我们在深度学习中的卷积指的是类似交叉相关运算,这是在从数字信号学中提取出的计算方法,二者最显著的区别是:
- 交叉相关:卷积核与图像重叠区域的特征
- 数学卷积:用于描述两个函数在不同位置的重叠程度。
而在数字信号领域中,卷积和交叉运算实际上只有一个符号位的区别,我们对图像的卷积(交叉相关运算)是将核在图像上滑动来计算结果,而这时候对图像进行翻转之后,相当于滑动的开始位置交换了,实际上区别不大。
因此,随后我们针对所有图像进行的卷积运算指的都是数字信号邻域的交叉相关运算和卷积。
一张图简单的解释:
他的计算过程是这样的:
0 ×0+1×1+3×2+4×3=19,
1 ×0+2×1+4×2+5×3=25,
3 ×0+4×1+6×2+7×3=37,
4 ×0+5×1+7×2+8×3=43.
代码实现:
def corr2d(X, K): #@save
"""计算二维互相关运算"""
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中的平移,应该仅导致隐藏表示H中的平移,他不应该受图像的具体位置影响,也就是说,卷积核的参数在不同的位置应当是相同的。
看平移不变性的数学表示:
[H]i,j=u+∑a∑b[V]a,b[X]i+a,j+b. [\mathbf{H}]_{i, j} = u + \sum_a\sum_b [\mathbf{V}]_{a, b} [\mathbf{X}]_{i+a, j+b}. [H]i,j=u+a∑b∑[V]a,b[X]i+a,j+b.
其中X是输入数据,i,j是图片内的像素位置,a和b为卷积核的宽高,H指像素区域的隐含表示,整个公式就是一个标准的二维交叉相关运算:对区域内的数据加权求和。这里是对输入图像在卷积核范围内的数据与卷积核相乘求和,这里的∑a\sum a∑a表示对每一列求和,∑b\sum b∑b表示按行求和,这样就将整个区域内的像素都进行的交叉相关运算。
而在这整个过程中,不管图像中具体的位置i,j在哪,卷积核V的大小都是固定的,并且他的值是一个独立的矩阵,与图像内容无关。
局部性
局部性简单来说就是在卷积神经网络的卷积核的权重计算更新的时候,只收集用来训练参数[H]i,j的相关信息,我们不应偏离到距(i,j)很远的地方。
这意味着在|a|>Δ或|b|>Δ的范围之外,我们可以设置[V]a,b=0。因此,我们可以将[H]i,j重写为:
[H]i,j=u+∑a=−ΔΔ∑b=−ΔΔ[V]a,b[X]i+a,j+b. [\mathbf{H}]_{i, j} = u + \sum_{a = -\Delta}^{\Delta} \sum_{b = -\Delta}^{\Delta} [\mathbf{V}]_{a, b} [\mathbf{X}]_{i+a, j+b}. [H]i,j=u+a=−Δ∑Δb=−Δ∑Δ[V]a,b[X]i+a,j+b.
这也就是说,将a和b的范围限制在 −Δ 到 Δ。确保卷积核的只在这个范围内计算而不会关注范围外的数据。
图像卷积
由之前提出我们在深度学习中应用的卷积实际上类似交叉相关运算,我们就可以得出图像卷积,实际上就是在图像创建类似滑动窗口的卷积核,并且计算卷积核内权重与图像的像素值的加权和。
如下图所示:
假设这里的输入图像的尺寸是nw∗nhn_{w} * n_{h}nw∗nh 卷积和的尺寸和kw∗khk_{w}*k_{h}kw∗kh 偏差b∈Rb\in Rb∈R 可以计算出输出的Y的形状为:(nw−kw+1,nh−kh+1)(n_{w}-k_{w}+1,n_{h}-k_{h}+1)(nw−kw+1,nh−kh+1)
最终计算得出的Y的值是:卷积核与图像的交叉相关运算的值再加上偏置项。
如上图所示,输入图像为3∗33*33∗3 卷积核为2∗22*22∗2 则输出形状为(3−2+1,3−2+1)(3-2+1,3-2+1)(3−2+1,3−2+1)即(2,2)
其中W即为卷积核,卷积核权重参数和偏置B是可学习的。
由于图像通常为二维数据(宽x高),因此在图像中应用的卷积是二维交叉相关运算,实际上我们还有常用的一维相关运算和三维相关运算。
一维相关运算相当于在一维张量上进行的卷积,通常适用于文本、语言和时间序列数据。
而三维相关运算,最为典型的是视频和医学图像以及气象地图这类三维数据。
卷积层
卷积层和其他的层一样,接受一个输入和指定一个输出,具有被训练参数,在训练的过程中生成计算图,通过反向传播更新参数。。。。。
卷积层对输入和卷积核权重进行互相关运算,并在添加标量偏置之后产生输出。所以,卷积层中的两个被训练的参数是卷积核权重和标量偏置。
这类卷积核权重与多层感知机的隐藏层权重和线性层的权重类似,都是通过训练更新学习的网络参数。
因此,我们可以基于Pytorch简单的构建一个卷积层:
class Conv2D(nn.Module):
def __init__(self,k_size):
self.weight = nn.Parameter(torch.randn(k_size))
self.bias = nn.Parameter(torch.zeros(k_size))
def forward(self, x):
return corr2d(x,self.weight) + self.bias
这里的权重我们进行随机初始化,并且将初始偏置都设为0,通过nn.Parameter实例化参数对象,并且保存到当前类属性。
学习卷积核
先看一个简单的通过交叉相关运算提取边缘的示例:
# 二维交叉相关示例
X = np.ones((8,10)) # 构建一个8x10的全为1的张量
X[:,3:7] = 0
print(X)
# 构造卷积核
K = np.array([[1,-1]])
print(corr2d(X,K))
首先构造一个8x10的全为1的张量,再把4-8行置零,模拟一个图像输入。
然后构建一个卷积核,这里使用[1,-1]即在窗口内相同的元素值为0,不同的元素值为1。
corr2d是之前构建的函数。
打印结果:
很显然,这样交叉相关运算只能提取垂直的边缘,因为这时候卷积核是横向的,并且输入图像中只有垂直的边缘,对代码进行简单的修改:
# 二维交叉相关示例
X = np.ones((8,10)) # 构建一个6x8的全为1的张量
X[3:7,:] = 0
X[3:7,0:3] = 1
X[3:7,7:] = 1
print(X)
# 构造卷积核
K = np.array([[1,0],[0,-1]])
print(corr2d(X,K))
输出:
接下来,如果我们只需寻找黑白边缘,那么以上[1, -1]的边缘检测器足以。然而,当有了更复杂数值的卷积核,或者连续的卷积层时,我们不可能手动设计滤波器。那么我们是否可以学习由X生成Y的卷积核呢?
现在让看看是否可以通过仅查看“输入-输出”对来学习由X生成Y的卷积核。 先构造一个卷积层,并将其卷积核初始化为随机张量。接下来,在每次迭代中,比较Y与卷积层输出的平方误差,然后计算梯度来更新卷积核。为了简单起见,我们在此使用内置的二维卷积层,并忽略偏置。
首先介绍一下nn.Conv2d的参数:
torch.nn.Conv2d(in_channels, out_channels, kernel_size, stride=1, padding=0, dilation=1, groups=1, bias=True, padding_mode='zeros', device=None, dtype=None)
其中:
in_channels(int): 输入通道数。对于图像数据,通常是 1(灰度图)或 3(RGB 图)。out_channels(int): 输出通道数。这是卷积层输出的特征图数量。kernel_size(int or tuple): 卷积核的大小。可以是一个整数(表示正方形卷积核),也可以是一个元组(表示矩形卷积核)。例如,kernel_size=3表示一个 3x3 的卷积核,kernel_size=(3, 5)表示一个 3x5 的卷积核。stride(int or tuple, optional): 卷积核在输入上移动的步幅。默认值为 1。可以是一个整数(表示在两个方向上的步幅相同),也可以是一个元组(表示在不同方向上的步幅不同)。例如,stride=2表示在两个方向上都移动 2 步。padding(int or tuple, optional): 输入的每边填充大小。默认值为 0。可以是一个整数(表示在所有四个方向上的填充相同),也可以是一个元组(表示在不同方向上的填充不同)。例如,padding=1表示在所有四个方向上各填充 1 个像素。dilation(int or tuple, optional): 卷积核元素之间的间距。默认值为 1。可以是一个整数(表示在两个方向上的间距相同),也可以是一个元组(表示在不同方向上的间距不同)。例如,dilation=2表示卷积核元素之间的间距为 2。groups(int, optional): 控制输入和输出之间的连接。默认值为 1,表示标准卷积。如果设置为in_channels,则表示深度可分离卷积(depthwise convolution)。bias(bool, optional): 是否添加偏置项。默认值为True。padding_mode(string, optional): 填充模式。默认值为'zeros',表示用零填充。其他选项包括'reflect'、'replicate'和'circular'。device(torch.device, optional): 指定张量创建的设备。默认值为None,表示使用当前默认设备。dtype(torch.dtype, optional): 指定张量的数据类型。默认值为None,表示使用当前默认数据类型
使用conv2d简单学习卷积核的代码实现:
# 学习卷积核
X = torch.ones((8,10)) # 输入
X[:,2:6] = 0
K = torch.tensor([[1,-1]]) # 真实权重
Y = torch.tensor(corr2d(X,K)) # 真实值
conv2d = nn.Conv2d(1,1,kernel_size=(1,2),bias=False) # 构建卷积层
nn.init.xavier_uniform_(conv2d.weight)
# 调整输入输出形状:批量大小、通道、高度、宽度
X = X.reshape((1,1,8,10))
Y = Y.reshape((1,1,8,9))
lr = 0.007 # 学习率
for i in range(50):
Y_hat = conv2d(X) # 预测值
l = (Y_hat - Y) ** 2 # 均方误差
conv2d.zero_grad() # 清除梯度
l.sum().backward() # 反向传播
conv2d.weight.data[:] -= lr * conv2d.weight.grad # 更新权重
print(f"epoch {i} loss: {l.sum()}")
print(conv2d.weight.data)
这里注意,先前进行交叉相关运算使用的是numpy构建输入和卷积核,并且在函数返回的也是numpy数组,需要对其转为tensor。
同时使用Xavier初始化来是初始权重更加合理。
经过50次迭代之后,查看最终的卷积核权重:
tensor([[[[ 0.9963, -0.9963]]]])
已经非常接近预定义的[1,-1]了。
卷积神经网络深入了解
填充
关于填充
针对之前的卷积运算,我们得到了输出形状关于输入的形状的一般规律,即:
(nw−kw+1,nh−kh+1) (n_{w}-k_{w}+1,n_{h}-k_{h}+1) (nw−kw+1,nh−kh+1)
输入的形状是输入的形状减去卷积核的形状再加一。
实际上在卷积神经网络中,我们为了进一步提取图像的主要特征和更多的减少输入数量,通常会对卷积层加入填充和步幅。
假设以下情景:
有时,在应用了连续的卷积之后,我们最终得到的输出远小于输入大小。这是由于卷积核的宽度和高度通常大于1所导致的。比如,一个240×240像素的图像,经过10层5×5的卷积后,将减少到200×200像素。
如此一来,原始图像的边界丢失了许多有用信息。而填充是解决此问题最有效的方法; 有时,我们可能希望大幅降低图像的宽度和高度。例如,如果我们发现原始的输入分辨率十分冗余。步幅则可以在这类情况下提供帮助。
总的来说:
填充: 更好的保留边界信息
步幅: 减少冗余像素,降低输出
首先是填充,在应用多层卷积时,我们常常丢失边缘像素。 由于我们通常使用小卷积核,因此对于任何单个卷积,我们可能只会丢失几个像素。 但随着我们应用许多连续卷积层,累积丢失的像素数就多了。 解决这个问题的简单方法即为填充(padding):在输入图像的边界填充元素(通常填充元素是0)。

图中的虚线部分的元素即为填充的元素,至于其中的元素为0,因为0乘以任何数得到的是0,再经过相加之后即不对结果产生影响,对这个部分的卷积就只保留了图像局部的特征。
填充之后的输出
原先我们对图像卷积输出的形状只受卷积核大小的影响,现在我们加入了填充,很显然,添加填充相当于对输入的形状扩大,因此加入填充之后,如果我们添加ph行填充(大约一半在顶部,一半在底部)和pw列填充(左侧大约一半,右侧一半),则输出形状将为:
(nh−kh+ph+1)×(nw−kw+pw+1)。 (n_h-k_h+p_h+1)\times(n_w-k_w+p_w+1)。 (nh−kh+ph+1)×(nw−kw+pw+1)。
这意味着输出的高度和宽度将分别增加ph和pw。
在许多情况下,我们需要设置ph=kh−1p_h=k_h-1ph=kh−1和pw=kw−1p_w=k_w-1pw=kw−1,使输入和输出具有相同的高度和宽度。
这样可以在构建网络时更容易地预测每个图层的输出形状。
假设khk_{h}kh是奇数,我们将在高度的两侧填充ph2\frac{p_{h}}{2}2ph行。 如果khk_{h}kh是偶数,则一种可能性是在输入顶部填充ph2\frac{p_{h}}{2}2ph行,在底部填充ph2\frac{p_{h}}{2}2ph行。同理,我们填充宽度的两侧。
卷积神经网络中卷积核的高度和宽度通常为奇数,例如1、3、5或7。 选择奇数的好处是,保持空间维度的同时,我们可以在顶部和底部填充相同数量的行,在左侧和右侧填充相同数量的列。
此外,使用奇数的核大小和填充大小也提供了书写上的便利。对于任何二维张量X,当满足: 1. 卷积核的大小是奇数; 2. 所有边的填充行数和列数相同; 3. 输出与输入具有相同高度和宽度 则可以得出:输出Y[i, j]是通过以输入X[i, j]为中心,与卷积核进行互相关计算得到的。
在pytorch中使用填充
在pytorch中对输入应用填充很简单,只需要在构造二维卷积层的时候在其中指定padding参数即可。
例如:
# 使用填充
X = torch.ones((8,8)).reshape((1,1,8,8))
k_size = (3,3)
padding = 1
conv = nn.Conv2d(1,1,k_size, padding=padding)
Y = conv(X)
print(Y.shape)
在这里指定了填充为1,这时候相当于向行列都添加一个行/列数据,填充后的输入形状是10x10,最后我们得到的结果应该是8-3+2+1 = 8
运行查看:
有时候我们需要指定在行列上不同的填充,使用元组即可:
# 使用填充
X = torch.ones((8,8)).reshape((1,1,8,8))
k_size = (3,3)
padding = (2,1)
conv = nn.Conv2d(1,1,k_size, padding=padding)
Y = conv(X)
print(Y.shape)
输出:
torch.Size([1, 1, 10, 8])
步幅
在计算互相关时,卷积窗口从输入张量的左上角开始,向下、向右滑动。 在前面的例子中,我们默认每次滑动一个元素。 但是,有时候为了高效计算或是缩减采样次数,卷积窗口可以跳过中间位置,每次滑动多个元素。
我们将每次滑动元素的数量称为步幅(stride)。
、
如上图所示,这是一个水平步幅为2,垂直步幅为3的互相关运算。对于3x3的输入,填充为1,卷积核为2x2他的输出形状是2x2,输出形状的计算公式:
⌊(nh−kh+ph+sh)/sh⌋×⌊(nw−kw+pw+sw)/sw⌋.\lfloor(n_h-k_h+p_h+s_h)/s_h\rfloor \times \lfloor(n_w-k_w+p_w+s_w)/s_w\rfloor.⌊(nh−kh+ph+sh)/sh⌋×⌊(nw−kw+pw+sw)/sw⌋.
即对原先的形状加上步幅之后再除以步幅。
特殊的,如果填充等于核大小减一,上面公式进一步简化:
⌊(nh+sh−1)/sh⌋×⌊(nw+sw−1)/sw⌋ \lfloor(n_h+s_h-1)/s_h\rfloor \times \lfloor(n_w+s_w-1)/s_w\rfloor ⌊(nh+sh−1)/sh⌋×⌊(nw+sw−1)/sw⌋
更进一步,如果输入的高度和宽度可以被垂直和水平步幅整除,则输出形状将为:
(nh/sh)×(nw/sw)(n_h/s_h) \times (n_w/s_w)(nh/sh)×(nw/sw)
例如:
X = torch.ones(6,6).reshape((1,1,6,6))
k_size = (3,3)
padding = (2,2)
stride = (2,2)
conv = nn.Conv2d(1,1,k_size, padding=padding,stride=stride)
Y = conv(X)
print(Y.shape)
以上示例满足卷积核为奇数,并且填充为卷积核-1,输入宽度和高度可以被步幅整除,最后的结果应当是:
(nh/sh)×(nw/sw)(n_h/s_h) \times (n_w/s_w)(nh/sh)×(nw/sw)
即8/2=4.
运行结果:
torch.Size([1, 1, 4, 4])
多输入和多输出通道
在之前示例中,我们使用的全部是单个卷积核处理单通道图像,但是实际情况中对于一个彩色图像的处理,他包含RGB三个通道,这时候使用一个卷积核就无法完成图像的处理任务,因此需要在卷积层对多输入通道进行处理。
当输入包含多个通道时,需要构造一个与输入数据具有相同输入通道数的卷积核,以便与输入数据进行互相关运算。假设输入的通道数为ci,那么卷积核的输入通道数也需要为ci。如果卷积核的窗口形状是kh×kw,那么当ci=1时,我们可以把卷积核看作形状为kh×kw的二维张量。
然而,当ci>1时,我们卷积核的每个输入通道将包含形状为kh×kw的张量。将这些张量ci连结在一起可以得到形状为ci×kh×kw的卷积核。由于输入和卷积核都有ci个通道,我们可以对每个通道输入的二维张量和卷积核的二维张量进行互相关运算,再对通道求和(将ci的结果相加)得到二维张量。这是多通道输入和多输入通道卷积核之间进行二维互相关运算的结果。
如下图所示:

实际上在进行多通道处理的时候,每个卷积核对应处理一个通道,再将每个通道相关运算的结果进行加权即得到一个二维的输出(先前的输入和卷积核都是三维的)。
也就是说,每个通道都有一个卷积核,结果是所有通道卷积结果的和。
举一个简单的例子,使用sobel算子对图像进行边缘检测:
import cv2
import torch
import torch.nn as nn
import numpy as np
frame = cv2.imread('img.png') # 读取图像
cv2.imshow("raw",frame)
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) # 转换颜色空间
image_tensor = torch.from_numpy(frame).permute(2, 0, 1).unsqueeze(0).float() # 调整顺序
k_size = 3 # 卷积核大小
sobel_x = torch.tensor([[-1, 0, 1],
[-2, 0, 2],
[-1, 0, 1]], dtype=torch.float32).unsqueeze(0).unsqueeze(0) #用于在水平方向检测边缘
sobel_y = torch.tensor([[-1, -2, -1],
[0, 0, 0],
[1, 2, 1]], dtype=torch.float32).unsqueeze(0).unsqueeze(0) # 用于在垂直方向检测边缘
# 扩展到三个通道
sobel_x = sobel_x.repeat(1,3,1,1)
sobel_y = sobel_y.repeat(1,3,1,1)
# 构建卷积层
conv_x = nn.Conv2d(3, 3, k_size, padding=k_size//2, bias=False)
conv_y = nn.Conv2d(3, 3, k_size, padding=k_size//2, bias=False)
# 设置权重
conv_x.weight.data=sobel_x
conv_y.weight.data = sobel_y
# 进行卷积运算
res_x = conv_x(image_tensor)
res_y = conv_y(image_tensor)
# 合并结果
output = torch.sqrt(res_x**2 + res_y**2)
output_image = output.squeeze(0).permute(1, 2, 0).detach().numpy()
output_image = np.clip(output_image, 0, 255).astype(np.uint8) # 确保像素值在 0-255 范围内
cv2.imshow("res",output_image)
cv2.waitKey(0)
这里通过opencv来读取图像数据,由于opencv默认读取的颜色空间是BGR,因此首先对其转换为RGB空间,然后将opencv的np数组转为张量之后交换维度:原先opencv的维度是(H,W,C),需要转为torch需要的C,H,W,之后归一化表示批量大小维度为1。
然后使用sobel算子对图像进行水平和垂直方向的边缘提取再通过均方进行结合。
需要注意的是卷积后的数值会超过255或者小于0,需要使用np的clip方法裁剪数据。
这里我们可以查看形状:
print(image_tensor.shape)
print(res_x.shape)

原先图像的张量是三通道的,经过卷积之后合并为一个通道,对应我们对多输入通道的处理。
展示一下边缘检测的结果:
以上是关于多输入通道的内容,到这里我们应该注意到还有一个问题:
虽然我们对多输入通道进行了处理,但是这时候输出的通道还是1,因此解决这个问题,还需要在卷积中引入多输出通道的概念。
在前言中介绍到,我们可以使用多个卷积核对应每个通道,实际上在单输出通道我们还是只用到一个卷积核,因此在多输出通道方面,自然就引入了多个卷积核处理多个输出通道。
在最流行的神经网络架构中,随着神经网络层数的加深,我们常会增加输出通道的维数,通过减少空间分辨率以获得更大的通道深度。直观地说,我们可以将每个通道看作对不同特征的响应。而现实可能更为复杂一些,因为每个通道不是独立学习的,而是为了共同使用而优化的。因此,多输出通道并不仅是学习多个单通道的检测器。
先前针对多个输入通道的情况,我们使用的卷积核的形状是:
ci∗kw∗kh c_{i}*k_{w}*k_h ci∗kw∗kh
当需要多个输出通道的时候,加入输出通道数量coc_{o}co:
co∗ci∗kw∗kh c_o*c_{i}*k_{w}*k_{h} co∗ci∗kw∗kh
在互相关运算中,每个输出通道先获取所有输入通道,再以对应该输出通道的卷积核计算出结果。
举个简单的例子,之前通过单通道的输出对图像图像通过sobel核卷积来提取边缘,现在我们尝试指定不同的输出通道来使卷积的结果在第一维有更多的数据序列。
代码如下:
import torch
import torch.nn as nn
import cv2
import numpy as np
import matplotlib.pyplot as plt
# 读取图像
image = cv2.imread('img.png') # 读取图像
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) # 转换颜色空间
# 将图像转换为 PyTorch 张量
image_tensor = torch.from_numpy(image).permute(2, 0, 1).unsqueeze(0).float() # 形状变为 (1, 3, H, W)
# 定义卷积层
k_size = 3 # 卷积核大小
in_channels = 3 # 输入通道数
out_channels = 6 # 输出通道数
# 构建卷积层
conv_layer = nn.Conv2d(in_channels, out_channels, k_size, padding=k_size//2, bias=False)
# 初始化卷积核权重(随机初始化)
nn.init.kaiming_normal_(conv_layer.weight.data)
# 进行卷积运算
output_tensor = conv_layer(image_tensor)
# 将输出张量转换回 numpy 数组
output_images = output_tensor.squeeze(0).detach().numpy() # 形状为 (6, H, W)
# 显示原始图像和每个输出特征图
plt.figure(figsize=(15, 10))
plt.subplot(2, 4, 1)
plt.imshow(image)
plt.title('Original Image')
plt.axis('off')
for i in range(out_channels):
plt.subplot(2, 4, i + 2)
plt.imshow(output_images[i], cmap='gray')
plt.title(f'Feature Map {i+1}')
plt.axis('off')
plt.show()
当需要使用nn.Conv2d指定多输出通道的时候,只需要指定out_channels参数即可。
上述代码沿用了之前通故opencv读取图像转为张量的思路,首先读取默认的BGR图像,然后转为RGB颜色空间,这时候有三个输入通道:R、G、B。但是opencv默认的维度排序方式是H,W,C因此我们需要将通道移动到前面,通过torch的permute()方法。
然后将通过unsqueeze()方法在第0维增加一个维度即输入通道的维度。
随后,实例化卷积层,指定必要的参数,并且通过He初始化来初始化权重。
nn.init.kaiming_normal_是 PyTorch 中的一种权重初始化方法,也称为 He 初始化。这种初始化方法主要用于激活函数为 ReLU 及其变体(如 LeakyReLU)的网络。He 初始化的目标是保持前向传播过程中信号的方差不变,从而加速训练过程并减少梯度消失或爆炸的问题。
接着对输入数据进行卷积,然后在删除第零维,将输入通道合并,只保留输出通道和宽高。
转为numpy数组,之后通过matplotlib的pyplot模块展示图像。
输出结果:
可以看出,输出的形状是六个不同通道的特征图,卷积核的形状是输出通道x输入通道x卷积核宽度x卷积核高度。
1x1卷积层
1×1卷积,即kh=kw=1,看起来似乎没有多大意义。
毕竟,卷积的本质是有效提取相邻像素间的相关特征,而1×1卷积显然没有此作用。
尽管如此,1×1仍然十分流行,经常包含在复杂深层网络的设计中。
因为使用了最小窗口,1×1卷积失去了卷积层的特有能力——在高度和宽度维度上,识别相邻元素间相互作用的能力。
其实1×1卷积的唯一计算发生在通道上。

如上图所示,1x1卷积层起的最主要的作用就是将多个通道进行合并。
为了体现这个特点,我们在上述代码加入:
one_conv = nn.Conv2d(6, 2, (1,1), bias=False)
output_one = one_conv(output_tensor)
output_images_one = output_tensor.squeeze(0).detach().numpy()
然后修改:
for i in range(2):
plt.subplot(2, 4, i + 2)
plt.imshow(output_images_one[i], cmap='gray')
plt.title(f'Feature Map {i+1}')
plt.axis('off')
结果:
1x1卷积核的妙处就在于:它能够最少丢失关键信息的情况下对多个通道进行合并。
池化层
关于池化
池化层,也称汇聚层,包含两个关键的方法:最大池化和平均池化。
最大池化即在窗口中选择最大的数值作为输出,平均池化则是计算窗口中所有数值的平均值作为输出。
与卷积层不同的是,池化层的输出是确定的,他没有需要学习的参数,只有需要指定的超参数(同样是核大小、填充、步幅)。
以一个代码来演示池化层的操作:
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
其中pool_size指池化窗口的尺寸,这里取相同的宽高。
首先创建一个与输出形状相同的张量(这里的输出形状与先前的卷积计算方法相同),然后遍历其纵横方向,根据指定的池化方式填充数据(最大或者平均)。
这里我们创建一个6x6的随机张量来测试:
X = torch.normal(1,1,(6,6))
print(X)
Y = pool2d(X, pool_size=(2,2), mode='max')
print(Y)
输出:
tensor([[ 1.2176, 2.5775, 0.2507, 1.3987, 2.6975, 0.8944],
[ 0.0882, 1.0366, 2.3431, 3.1956, -0.2054, 0.3737],
[ 0.7165, 0.7571, 0.6263, 0.7372, 0.5970, -1.6952],
[ 0.5598, 1.4197, 0.9089, 1.3324, 0.7589, 1.7959],
[ 0.7285, 0.9830, 1.6982, -0.1382, 1.0700, 1.1500],
[ 2.2106, 2.9214, 1.3737, 1.4380, -0.4516, -0.0349]])
tensor([[2.5775, 2.5775, 3.1956, 3.1956, 2.6975],
[1.0366, 2.3431, 3.1956, 3.1956, 0.5970],
[1.4197, 1.4197, 1.3324, 1.3324, 1.7959],
[1.4197, 1.6982, 1.6982, 1.3324, 1.7959],
[2.9214, 2.9214, 1.6982, 1.4380, 1.1500]])
填充和步幅
与卷积层一样,汇聚层也可以改变输出形状。和以前一样,我们可以通过填充和步幅以获得所需的输出形状。 下面,我们用深度学习框架中内置的二维最大汇聚层,来演示汇聚层中填充和步幅的使用。 我们首先构造了一个输入张量X,它有四个维度,其中样本数和通道数都是1。
X = torch.normal(1,1.5,(6,6)).unsqueeze(0).unsqueeze(0)
print(X)
然后,指定参数和构建池化层以及运算:
padding = 1
k_size = (3,3)
pool = nn.MaxPool2d(kernel_size=(2,2),padding=padding)
Y = pool(X)
print(Y)
当指定填充为1的时候,输出的形状为4x4即输入-核+填充
同样指定步幅:
X = torch.normal(1,1.5,(6,6)).unsqueeze(0).unsqueeze(0)
print(X)
padding = 1
k_size = 2
stride = 1
pool = nn.MaxPool2d(kernel_size=(2,2),padding=padding,stride=stride)
Y = pool(X)
print(Y)
这里输入形状为6,填充为1,核为2,步幅为1,所以计算输出形状:
6+1∗2−2+11=7 \frac{{6+1*2-2+1}}{1}=7 16+1∗2−2+1=7
当我们修改步幅更大:
stride = 2
计算输出形状:
6+1∗2−2+22=4 \frac{{6+1*2-2+2}}{2}=4 26+1∗2−2+2=4
在处理多通道输入数据时,汇聚层在每个输入通道上单独运算,而不是像卷积层一样在通道上对输入进行汇总。 这意味着汇聚层的输出通道数与输入通道数相同。 下面,我们将在通道维度上连结张量X和X + 1,以构建具有2个通道的输入。
# 在第二维(输入通道)将张量连接
X = torch.normal(1,1.5,(6,6)).reshape(1,1,6,6)
X = torch.cat((X,X+1),1)
print(X.shape)
pool2d = nn.MaxPool2d(3, padding=1, stride=2)
Y = pool2d(X)
print(Y.shape,Y)

输出的通道数仍然是2。
更多推荐

所有评论(0)