Appearance
使用卷积进行泛化(上)
线性层中有大量可优化参数,能偶拟合(甚至过拟合)数据。但其在记忆训练集方面的表现优于对特性的泛化能力。由于需要全连接设置来检测图像中鸟或飞机的各种可能平移,我们即产生了过多的参数(使模型更容易记忆训练集),又缺乏位置不变性,我们可以通过数据增强来缓解这一问题。但这无法解决参数过多的问题。因此需要用卷积来替换神经网络单元中的密集全连接仿射变换。
卷积的意义
卷积可以实现局部性和平移不变性。在原来的处理中我们将图像是为一维视图并计算所有像素于一组权重相乘之后的加权和。而如果我们要识别物体通常需要关注邻近像素的排列方式,而对相距较远的像素组合则不感兴趣。要将这种直觉转为数学形式,可以计算一个像素与其最近像素的加权和
卷积的作用
通过卷积操作有如下优势:
- 局部领域操作
- 平移不变性
- 参数更少的模型 即对于卷积层,参数数量不同于全连接模型那样取决于图像的像素数量而是取决于卷积核大小(如3×3、5×5等)以及再模型中决定使用的卷积滤波器(或输出通道)的数量。
卷积的实际应用
对于PyTorch中的卷积,nn.Conv1d用于时间序列,nn.Conv2d用于图像,nn.Conv3d用于处理立体数据或视频,在这里我们使用nn.Conv2d处理图像,同时我们需要指定输出特征的数量以及卷积核的大小。在这里我们每个像素有3个特征(RGB通道),输出通道可以任意设定;同时将卷积核大小固定为 kernel_size=3快速设定:
Python
conv = nn.Conv2d(3, 16, kernel_size=3)
print(conv.weight.shape, conv.bias.shape)Text
torch.Size([16, 3, 3, 3]) torch.Size([16])对于权重张量的形状推导: 由于卷积核的尺寸是 3×3×3(即in_ch×3×3)的三维卷积核。而一个卷积核只能提取一个输出通道,为了得到16个不同的特征图需要16个独立的三位卷积核,即:
也就是为什么conv.weight.shape的输出是torch.Size([16, 3, 3, 3])。由于我们由16个输出通道,因此需要16个偏置值,也即torch.Size([16])。 卷积可以让我们使用更小的模型寻找局部模式,其权重会在整个图像上进行优化,此外conv.weight与conv.bias都是随机初始化的,要通过卷积核调用图像需要将图像输入调整为
Python
img, _ = cifar2[0]
output = conv(img.unsqueeze(0))
print(img.unsqueeze(0).shape, output.shape)Text
torch.Size([1, 3, 32, 32]) torch.Size([1, 16, 30, 30])还可以输出图像与卷积后的图像,其中卷积后的图像丢失了一些像素。
边界填充
上面的问题是由于图像边界处理方式导致的副作用。若将卷积核作为 PyTorch会在输入图像内部滑动卷积,得到宽度为width - kernel_width + 1的水平位置和垂直位置,也即为什么每个维度会损失两个像素。 在PyTorch中也可以在图像边界进行填充,只需要为卷积增加参数padding = 1,增加的像素在计算时会被记作0。
Python
conv = nn.Conv2d(3, 16, kernel_size=3, padding=1)
此时输入与输出张量是相同的。此外是否填充对权重和偏置的大小是不会影响的,这有助于我们将卷积与图像尺寸调整区分开;当我们处理更复杂的结构时若干卷积操作后张量的尺寸也会保持一致,以便进行加法或差分运算。
使用卷积检测特征
我们可以手动设置卷积权重来观察其对CIFAR图像的影响:
Python
with torch.no_grad():
conv.bias.zero_()
with torch.no_grad():
conv.weight.fill_(1.0 / 9.0)
output = conv(img.unsqueeze(0))
plt.imshow(output[0,0].detach(), cmap='gray')
plt.show()这会生成模糊版本的图像,输出的每个像素都是其输入相邻像素的平均值。还可以尝试不同的方法:
Python
conv = nn.Conv2d(3, 1, kernel_size=3, padding=1)
with torch.no_grad():
conv.weight[:] = torch.tensor([[-1.0, 0.0, 1.0],
[-1.0, 0.0, 1.0],
[-1.0, 0.0, 1.0]])
conv.bias_zero_()此时对(2,2)的任意位置的加权和:o22=i13-i11+i23-i21+i33-i31 相当于将i22右侧的像素值减去i22左侧所有像素值,对于亮度不同的相邻区域,o22将取较大值,二亮度均匀的区域o22则为0。此外还有许多不同的滤波器,以提取不同的特征。可以说据卷积神经网络是通过不同的滤波器组的卷积核将多通道图像转变为另一个多通道图像,其中不同通道对应不同的特征。 
通过深度和池化进一步探索
卷积带来了局部性和平移不变性,但由于我们无法得知图像中实际物体的结构对应的像素宽度,而将整张图片卷积则回到了原始的全连接模式,因此采用“连续卷积+下采样(池化)”的方式交替堆叠,也被称作增大感受野(Receptive Field)。 从大到小:下采样 下采样可以通过不同方式实现。例如将图像缩放一半,相当于将四个相邻的像素作为输入,产生一个像素作为输出,而根据输入值计算输出值的过程可以由我们自己决定:
- 对四个像素取平均值。即平均池化,在早期是常见做法,现在已经逐渐失宠
- 取四个像素中的最大值。即最大池化,是目前常用的方法,但会丢失其余四分之三数据。
- 进行步长卷积。即仅计算每隔N个像素的值,如步幅为2的 3×4 卷积会从前一层所用像素中获取输入。 后文以最大池化为例:如图所示,采用不重叠的2×2区域,取每个区域的最大值作为缩小尺度下的新像素,卷积层输出的图像后续会采用线形层一样的激活函数进行处理,具有较大的数值幅度。在某些对应特征被检测时,我们可以确保检测到的特征能够保存下来,但会牺牲较弱的相应。

Python
pool = nn.MaxPool2d(2)
output = pool(img.unsqueeze(0))
print(img.unsqueeze_(0).shape, output.shape)输入的张量为[1,3,32,32]而输出的张量为[1,3,16,16]。 将卷积与下采样结合 如图为对8×8的图像采用3×3的卷积核后,使用下采样将图像缩小一半得到4×4的图像,再通过一次3×3的卷积,形成新的特征。 
输出像素的感受野
如图,当第二个3×3卷积核在其卷积输出中产生21时,该值基于第一个最大池化输出的左上角3×3像素。而这些像素又对应第一个卷积输出的左上角6×6像素区域,该区域则由第一个卷积基于左上角7×7像素计算得出。因此,第二个卷积输出中的像素受到7×7输入方阵的影响。第一个卷积在生成角落输出时还隐式使用了“填充”的列和行;否则,第二个卷积输出中某个给定像素(边界处除外)将对应一个8×8的输入像素方阵。用专业术语来说,这种由3×3卷积、2×2最大池化、3×3卷积构成的网络结构中,某个输出神经元的感受野为8×8。
构建神经网络
将上述方法结合,可以构建检测鸟类和飞机的卷积神经网络,引入nn.Conv2d与nn.MaxPool2d
Python
model = nn.Sequential(
nn.Conv2d(3, 64, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1)),
nn.Tanh(),
nn.MaxPool2d(2),
nn.Conv2d(16, 8 , kernel_size=(3, 3), stride=(2, 2), padding=(1, 1)),
nn.Tanh(),
nn.MaxPool2d(2),
# ...
)回到本章开头,我们要将8通道的8×8图像转化为一维向量,并通过全连接层完善神经网络:
Python
model = nn.Sequential(
nn.Conv2d(3, 64, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1)),
nn.Tanh(),
nn.MaxPool2d(2),
nn.Conv2d(16, 8 , kernel_size=(3, 3), stride=(2, 2), padding=(1, 1)),
nn.Tanh(),
nn.MaxPool2d(2),
# ...
nn.Linear(8*8*8, 32),
nn.Tanh(),
nn.Linear(32, 2),
)这段代码对应下图所示的神经网络:
忽略代码中缺少的内容,线形层大小取决于MaxPool2d输出的预期尺寸:
Python
numel_list = [p.numel() for p in model.parameters()]
print(sum(numel_list),numel_list)模型参数量为19434 [1728, 64, 1152, 8, 16384, 32, 64, 2],对于小图像处理而言该参数量非常合理,为了增加模型的容量,可以提高卷积层输出的通道数(即每个卷积层生成的特征数量),也会导致线性层的大小随之增加。
nn.Module的子类化
本节将构建自己的nn.Module子类,并可以像使用nn.Sequential一样使用他们。nn.Sequential只支持单一的、线性的从头到尾的顺序计算(A层 nn.Sequential是无法直接实现的。因此,我们需要通过继承 nn.Module 来获得绝对的灵活性。这必须要实现forward函数。同时构造函数需要__init__以声明和实例化模块中所需的组件,以在forward函数中调用,并方便管理参数。在__init__的第一行,必须先调用super().__init__以初始化PyTorch底层的nn.Module基类。
将神经网络作为nn.Module实现
Python
class Net(nn.Module):
def __init__(self):
#必须先调用super().__init__()
super().__init__()
# 在__init__中定义和保存子模块
self.conv1 = nn.Conv2d(3, 16, kernel_size=3, padding=1)
self.act1 = nn.Tanh()
self.pool1 = nn.MaxPool2d(2)
self.conv2 = nn.Conv2d(16, 8, kernel_size=3, padding=1)
self.act2 = nn.Tanh()
self.pool2 = nn.MaxPool2d(2)
self.fc1 = nn.Linear(8 * 8 * 8, 32)
self.act3 = nn.Tanh()
self.fc2 = nn.Linear(32, 2)
# 至少定义一个forward函数决定计算逻辑
def forward(self, x):
out = self.pool1(self.act1(self.conv1(x)))
out = self.pool2(self.act2(self.conv2(out)))
out = out.view(-1, 8 * 8 * 8)
out = self.act3(self.fc1(out))
out = self.fc2(out)
return outNet类子块类似之前的nn.Sequential模型,但通过显示编写前向传播函数,可以直接操作self.pool3的输出,并表用view方法将其转化为conv2,减少像素空间MaxPool2d(2),与降低全连接层输出fc1(512->32)到fc2(32->2),随着层数增加,中间变量的总数据量不断减小“Shrinking”
| 阶段 / 网络层 | 输出特征图形状 (C × H × W) | 总数值量大小 (C × H × W) | 维度如何变化 |
|---|---|---|---|
输入图像 x | 3×32×32 | 3,072 | 起始状态 |
conv1 + act1 | 16×32×32 | 16,384 | 空间没变,通道激增(见第二部分) |
pool1 | 16×16×16 | 4,096 | 空间下采样,总量大幅萎缩 |
conv2 + act2 | 8×16×16 | 2,048 | 通道数减少 (16→ 8),总量继续缩减 |
pool2 | 8×8×8 | 512 | 空间再次下采样 |
fc1 | 长度为 32 的向量 | 32 | 全连接层大幅降维 |
fc2 (最终输出) | 长度为 2 的向量 | 2 | 最终映射到 2 个分类标签 |
该Net属于“快速信息压缩”网络,将通道和空间都进行压缩,适合小兔和浅网络。而在现代大网络(如ResNet中),虽然空间分辨率(H×W)在通过池化缩小,但通道数(C)通常翻倍增加。但由于空间压缩得更快,总体的数据体量仍在逐步减少。而对网络第一层conv1虽然将输入变大了几倍,但其内部局部像素点的计算仍然遵循输入和输出维度大致均衡的原则。 | |||
实际通过kernel_size=3的卷积核在3个输入通道滑过 |
PyTorch如何追踪参数和子模块
将nn.Module的实例赋值给nn.Module的属性会自动将该模块注册为子模块。
函数式API
PyTorch 为每个 nn 模块都提供了对应的函数式版本。这里所说的“函数式”是指“没有内部状态”——换句话说,就是“其输出值完全且唯一地由输入参数的值决定”。
Python
import torch.nn.functional as F
class Net(nn.Module):
def __init__(self):
super().__init__()
self.conv1 = nn.Conv2d(3, 16, kernel_size=3, padding=1)
self.conv2 = nn.Conv2d(16, 8, kernel_size=3, padding=1)
self.fc1 = nn.Linear(8 * 8 * 8, 32)
self.fc2 = nn.Linear(32, 2)
def forward(self, x):
out = F.max_pool2d(torch.tanh(self.conv1(x)), 2)
out = F.max_pool2d(torch.tanh(self.conv2(out)), 2)
out = out.view(-1, 8 * 8 * 8)
out = torch.tanh(self.fc1(out))
out = self.fc2(out)
return out这与之前的代码完全等价且更加简洁,将之前的self.act = nn.Tanh() 和 self.pool = nn.MaxPool2d(2)直接变成了forward函数里的调用:torch.tanh()和F.max_pool2d(),这是由于激活函数(Tanh)和池化层(MaxPool)是无状态的(Stateless)。激活函数(Tanh)和池化层(MaxPool)是无状态的(Stateless)。因此如果一个操作有状态(有可学习参数),它必须放在__init__里作为nn.Module实例化;如果一个操作没有状态,则可以直接在forward里用 Functional API。而只有在需要将模型“量化”(如将32 位浮点数压缩成 8 位整数,以便部署到手机上)时则必须将“无状态”的激活函数变为“有状态的”,需要使用nn.ReLU()等类似指令。或在分析工具中需要重复使用时。 这时已经可以用模型进行训练
Python
model = Net()
print(model(img.unsqueeze(0)))得到输出tensor([[0.1249, 0.0684]], grad_fn=<AddmmBackward0>)