李宏毅-卷积神经网络CNN
如果使用全连接层:第一层的weight就有3*10^7个
观察 1:检测模式不需要整张图像
很多重要的pattern只要看小范围即可
简化1:感受野
根据观察1 可以做第1个简化,卷积神经网络会设定一个区域,即感受野(receptive field),每个神经元都只关心自己的感受野里面发生的事情
这个神经元会把3×3×3的数值“拉直”变成一个长度是3×3×3=27维的向量,再把这27维的向量作为神经元的输入,这个神经元会给27维的向量的每个维度一个权重,所以这个神经元有3×3×3=27个权重,再加上偏置(bias)得到输出。这个输出再送给下一层的神经元当作输入。
感受野彼此之间也可以是重叠的,比如绿色的神经元的感受野跟蓝色的、黄色的神经元都有一些重叠的空间。我们没有办法检测所有的模式,所以同个范围可以有多个不同的神经元,即多个神经元可以去守备同一个感受野。
感受野可以有大有小,因为模式有的比较小,有的比较大。有的模式也许在3×3的范围内就可以被检测出来,有的模式也许要11×11的范围才能被检测出来。此外,感受野可以只考虑某些通道。目前感受野是RGB三个通道都考虑,但也许有些模式只在红色或蓝色的通道会出现,即有的神经元可以只考虑一个通道。之后在讲到网络压缩的时候,会讲到这种网络的架构。感受野不仅可以是正方形的,例如刚才举的例子里面3×3、11×11,也可以是长方形的,完全可以根据对问题的理解来设计感受野。虽然感受野可以任意设计,但下面要跟大家讲一下最经典的感受野安排方式。
一般在做图像识别的时候,可能不会觉得有些模式只出现在某一个通道里面,所以会看全部的通道。既然会看全部的通道,那么在描述一个感受野的时候,只要讲它的高跟宽,不用讲它的深度,因为它的深度就等于通道数,而高跟宽合起来叫做核大小。图4.8中的核大小就是3×3。在图像识别里面,一般核大小不会设太大,3×3的核大小就足够了,7×7、9×9算是蛮大的核大小。如果核大小都是3×3,意味着我们觉得在做图像识别的时候,重要的模式都只在3×3这么小的范围内就可以被检测出来了。但有些模式也许很大,也许3×3的范围没办法检测出来,后面我们会再回答这个问题。常见的感受野设定方式就是核大小为3×3。
步幅是一个超参数,需要人为调整。因为希望感受野跟感受野之间是有重叠的,所以步幅往往不会设太大,一般设为1或2。
Q:为什么希望感受野之间是有重叠的呢?
A: 因为假设感受野完全没有重叠,如果有一个模式正好出现在两个感受野的交界上面,就没有任何神经元去检测它,这个模式可能会丢失,所以希望感受野彼此之间有高度的重叠。如令步幅=2,感受野就会重叠。
我们就按照这个方式扫过整张图像,所以整张图像里面每一寸土地都是有被某一个感受野覆盖的。也就是图像里面每个位置都有一群神经元在检测那个地方,有没有出现某些模式。这个是第1个简化
观察 2:同样的模式可能会出现在图像的不同区域
第2个观察是同样的模式,可能会出现在图像的不同区域。比如说模式鸟嘴,它可能出现在图像的左上角,也可能出现在图像的中间
如果不同的守备范围都要有一个检测鸟嘴的神经元,参数量会太多了,因此需要做出相应的简化。
简化 2:共享参数
可以让不同感受野的神经元共享参数,也就是做参数共享(parametersharing),如图4.13所示。所谓参数共享就是两个神经元的权重完全是一样的。
上面神经元跟下面神经元守备的感受野是不一样的,但是它们的参数是相同的。虽然两个神经元的参数是一模一样,但它们的输出不会永远都是一样的,因为它们的输入是不一样的,它们照顾的范围是不一样的。
因为输入不一样的关系,所以就算是两个神经元共用参数,它们的输出也不会是一样的。所以这是第2个简化,让一些神经元可以共享参数,共享的方式完全可以自己决定。接下来将介绍图像识别方面,常见的共享方法是如何设定的。
每个感受野都有一组神经元在负责守备,比如64个神经元,它们彼此之间可以共享参数。图4.16中使用一样的颜色代表这两个神经元共享一样的参数,所以每个感受野都只有一组参数,就是上面感受野的第1个神经元会跟下面感受野的第1个神经元共用参数,上面感受野的第2个神经元跟下面感受野的第2个神经元共用参数······ 所以每个感受野都只有一组参数而已,这些参数称为滤波器(filter)。这是第2个简化的方法
观察 3:下采样不影响模式检测
把一张比较大的图像做下采样(downsampling),把图像偶数的列都拿掉,奇数的行都拿掉,图像变成为原来的1/4,但是不会影响里面是什么东西。
简化 3:汇聚
根据第3个观察,汇聚被用到了图像识别中。汇聚没有参数,所以它不是一个层,它里面没有权重,它没有要学习的东西,汇聚比较像Sigmoid、ReLU等激活函数。因为它里面是没有要学习的参数的,它就是一个操作符(operator),其行为都是固定好的,不需要根据数据学任何东西。
做完卷积以后,往往后面还会搭配汇聚。汇聚就是把图像变小。做完卷积以后会得到一张图像,这张图像里面有很多的通道。做完汇聚以后,这张图像的通道不变。
一般架构就是卷积加汇聚,汇聚是可有可无的,很多人可能会选择不用汇聚。如图4.30所示,如果做完几次卷积和汇聚以后,把汇聚的输出做扁平化(flatten),再把这个向量丢进全连接层里面,最终还要过个softmax 来得到图像识别的结果。这就是一个经典的图像识别的网络,里面有卷积、汇聚和扁平化,最后再通过几个全连接层或softmax 来得到图像识别的结果。
卷积神经网络的应用:下围棋
除了图像识别以外,卷积神经网络另外一个最常见的应用是用来下围棋,以AlphaGo为例。下围棋其实是一个分类的问题,网络的输入是棋盘上黑子跟白子的位置,输出就是下一步应该要落子的位置。网络的输入是一个向量,棋盘上有19×19 个位置,可以把一个棋盘表示成一个19×19 维的向量。在这个向量里面,如果某个位置有一个黑子,这个位置就填1,如果有白子,就填-1,如果没有子,就填0。不一定要黑子是1,白子是-1,没有子就是0,这只是一个可能的表示方式。通过把棋盘表示成向量,网络就可以知道棋盘上的盘势。把这个向量输到一个网络里面,下围棋就可以看成一个分类的问题,通过网络去预测下一步应该落子的最佳位置,所以下围棋就是一个有19×19个类别的分类问题,网络会输出19×19个类别中的最好类别,据此选择下一步落子的位置。这个问题可以用一个全连接网络来解决,但用卷积神经网络的效果更好。
Q: 为什么卷积神经网络可以用在下围棋上?
A:
- 首先一个棋盘可以看作是一个分辨率为19×19的图像。一般图像很大,100×100的分辨率的图像,都是很小的图像了。但是棋盘是一个更小的图像,其分辨率只有19×19。这个图像里面每个像素代表棋盘上一个可以落子的位置。
- 一般图像的通道就是RGB。而在AlphaGo 的原始论文里面,每个棋盘的位置,即每个棋盘上的像素是用48个通道来描述,即棋盘上的每个位置都用48个数字来描述那个位置发生的事情[1]。48个数字是围棋高手设计出来的,包括比如这个位置是不是要被叫吃了,这个位置旁边有没有颜色不一样的等等。所以当我们用48个数字来描述棋盘上的一个位置时,这个棋盘就是19×19 的分辨率的图像,其通道就是48。
- 卷积神经网络其实并不是随便用都会好的,它是为图像设计的。如果一个问题跟图像没有共通的特性,就不该用卷积神经网络。既然下围棋可以用卷积神经网络,这意味着围棋跟图像有共同的特性。图像上的第 1 个观察是,只需要看小范围就可以知道很多重要的模式。下围棋也是一样的,图4.32 中的模式不用看整个棋盘的盘势,就知道发生了什么事(白子被黑子围住了)。接下来黑子如果放在被围住的白子下面,就可以把白子提走。白子如果放在白子下面,被围住的白子才不会被提走。其实AlphaGo 的第1层的滤波器大小就是5×5,所以显然设计这个网络的人觉得棋盘上很多重要的模式,也许看5×5的范围就可以知道。
- 此外,图像上的第2个观察是同样的模式可能会出现在不同的位置,在下围棋里面也是一样的。如图4.33 所示,这个叫吃的模式,它可以出现在棋盘上的任何位置,它可以出现在左上角,也可以出现在右下角,所以从这个观点来看图像跟下围棋有很多共同之处。
在做图像的时候都会做汇聚,一张图像做下采样以后,并不会影响我们对图像中物体的判断。但汇聚对于下围棋这种精细的任务并不实用,下围棋时随便拿掉一个列拿掉一个行,整个棋局就不一样。AlphaGo 在 Nature 上的论文正文里面没有提它用的网络架构,而是在附件中介绍了这个细节。AlphaGo 把一个棋盘看作19×19×48 大小的图像。接下来它有做零填充。它的滤波器的大小是5×5,然后有k=192个滤波器,k的值是试出来的,它也试了128、256,发现 192 的效果最好。这是第1层,步幅=1,使用了ReLU。在第2层到第12层都有做零填充。核大小都是3×3,一样是k个滤波器,也就是每一层都是192个滤波器,步幅一样设1,这样叠了很多层以后,因为是一个分类的问题,最后加上了一个softmax,没有用汇聚,所以这是一个很好的设计类神经网络的例子。在下围棋的时候不适合用汇聚。所以我们要想清楚,在用一个网络架构的时候,这个网络的架构到底代表什么意思,它适不适合用在这个任务上。
其实卷积神经网络不能处理图像放大缩小或者是旋转的问题,假设给卷积神经网络看的狗的图像大小都相同,它可以识别这是一只狗。当把这个图像放大的时候,它可能就不能识别这张图像是一只狗。卷积神经网络就是这么“笨”,对它来说,这是两张图像。虽然两张图像的形状是一模一样的,但是如果把它们“拉直”成向量,里面的数值就是不一样的。虽然人眼一看觉得两张图像的形状很像,但对卷积神经网络来说它们是非常不一样的。所以事实上,卷积神经网络并不能够处理图像放大缩小或者是旋转的问题。假设图像里面的物体都是比较小的,当卷积神经网络在某种大小的图像上面学会做图像识别,我们把物体放大,它的性能就会降低不少,卷积神经网络并没有想像的那么强。**因此在做图像识别的时候往往都要做数据增强。所谓数据增强就是把训练数据每张图像里面截一小块出来放大,让卷积神经网络看过不同大小的模式;把图像旋转,让它看过某一个物体旋转以后长什么样子,卷积神经网络才会做到好的结果。**卷积神经网络不能够处理缩放(scaling)跟旋转(rotation)的问题,但Spatial Transformer Layer 网络架构可以处理这个问题。
6.1 从全连接层到卷积
此例以说明全连接层处理图片的时候会遇到参数过多 模型过大的问题
我们之前讨论的多层感知机十分适合处理表格数据,其中行对应样本,列对应特征。 对于表格数据,我们寻找的模式可能涉及特征之间的交互,但是我们不能预先假设任何与特征交互相关的先验结构。 此时,多层感知机可能是最好的选择,然而对于高维感知数据,这种缺少结构的网络可能会变得不实用。
例如,在之前猫狗分类的例子中:假设我们有一个足够充分的照片数据集,数据集中是拥有标注的照片,每张照片具有百万级像素,这意味着网络的每次输入都有一百万个维度。 即使将隐藏层维度降低到1000,这个全连接层也将有10^6 * 10^3 = 10^9个参数。 想要训练这个模型将不可实现,因为需要有大量的GPU、分布式优化训练的经验和超乎常人的耐心。
有些读者可能会反对这个观点,认为要求百万像素的分辨率可能不是必要的。 然而,即使分辨率减小为十万像素,使用1000个隐藏单元的隐藏层也可能不足以学习到良好的图像特征,在真实的系统中我们仍然需要数十亿个参数。 此外,拟合如此多的参数还需要收集大量的数据。 然而,如今人类和机器都能很好地区分猫和狗:这是因为图像中本就拥有丰富的结构,而这些结构可以被人类和机器学习模型使用。 卷积神经网络(convolutional neural networks,CNN)是机器学习利用自然图像中一些已知结构的创造性方法。
所以说卷积是特殊的全连接层
6.2 图像卷积
总结
- 核矩阵和偏移是可学习的参数
- 核矩阵的大小是超参数
互相关运算
严格来说,卷积层是个错误的叫法,因为它所表达的运算其实是互相关运算(cross-correlation),而不是卷积运算。 根据 6.1节中的描述,在卷积层中,输入张量和核张量通过互相关运算产生输出张量。
稍后,我们将看到如何通过在图像边界周围填充零来保证有足够的空间移动卷积核,从而保持输出大小不变。 接下来,我们在corr2d函数中实现如上过程,该函数接受输入张量X和卷积核张量K,并返回输出张量Y。
import torch
from torch import nn
from d2l import torch as d2l
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
矩阵的哈达玛积,对应位置元素的乘积,然后对乘积求和
卷积层
卷积层对输入和卷积核权重进行互相关运算,并在添加标量偏置之后产生输出。 所以,卷积层中的两个被训练的参数是卷积核权重和标量偏置。 就像我们之前随机初始化全连接层一样,在训练基于卷积层的模型时,我们也随机初始化卷积核权重。
基于上面定义的corr2d函数实现二维卷积层。在__init__构造函数中,将weight和bias声明为两个模型参数。前向传播函数调用corr2d函数并添加偏置。
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
高度和宽度分别为h和w的卷积核可以被称为h * w卷积或h * w卷积核。 我们也将带有h * w卷积核的卷积层称为h * w卷积层。
图像中目标的边缘检测
如下是卷积层的一个简单应用:通过找到像素变化的位置,来检测图像中不同颜色的边缘。 首先,我们构造一个6*8像素的黑白图像。中间四列为黑色(0),其余像素为白色(1)
X = torch.ones((6, 8))
X[:, 2:6] = 0
X
接下来,我们构造一个高度为1、宽度为2的卷积核K。当进行互相关运算时,如果水平相邻的两元素相同,则输出为零,否则输出为非零。
K = torch.tensor([[1.0, -1.0]])
现在,我们对参数X(输入)和K(卷积核)执行互相关运算。 如下所示,输出Y中的1代表从白色到黑色的边缘,-1代表从黑色到白色的边缘,其他情况的输出为0。
Y = corr2d(X, K)
Y
现在我们将输入的二维图像转置,再进行如上的互相关运算。 其输出如下,之前检测到的垂直边缘消失了。 不出所料,这个卷积核K只可以检测垂直边缘,无法检测水平边缘。
corr2d(X.t(), K)
学习卷积核
如果我们只需寻找黑白边缘,那么以上[1, -1]的边缘检测器足以。然而,当有了更复杂数值的卷积核,或者连续的卷积层时,我们不可能手动设计滤波器。那么我们是否可以学习由X生成Y的卷积核呢?
现在让我们看看是否可以通过仅查看“输入-输出”对来学习由X生成Y的卷积核。 我们先构造一个卷积层,并将其卷积核初始化为随机张量。接下来,在每次迭代中,我们比较Y与卷积层输出的平方误差,然后计算梯度来更新卷积核。为了简单起见,我们在此使用内置的二维卷积层,并忽略偏置。
# 构造一个二维卷积层,它具有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}')
在10次迭代之后,误差已经降到足够低。现在我们来看看我们所学的卷积核的权重张量。
conv2d.weight.data.reshape((1, 2))
细心的读者一定会发现,我们学习到的卷积核权重非常接近我们之前定义的卷积核K。
6.3 填充和步幅
卷积的输出形状取决于输入形状和卷积核的形状。
还有什么因素会影响输出的大小呢?本节我们将介绍填充(padding)和步幅(stride)。假设以下情景: 有时,在应用了连续的卷积之后,我们最终得到的输出远小于输入大小。这是由于卷积核的宽度和高度通常大于1所导致的。如此一来,原始图像的边界丢失了许多有用信息。而填充是解决此问题最有效的方法; 有时,我们可能希望大幅降低图像的宽度和高度。例如,如果我们发现原始的输入分辨率十分冗余。步幅则可以在这类情况下提供帮助。
填充
如上所述,在应用多层卷积时,我们常常丢失边缘像素。 由于我们通常使用小卷积核,因此对于任何单个卷积,我们可能只会丢失几个像素。 但随着我们应用许多连续卷积层,累积丢失的像素数就多了。 解决这个问题的简单方法即为填充(padding):在输入图像的边界填充元素(通常填充元素是0)。
卷积神经网络中卷积核的高度和宽度通常为奇数,例如1、3、5或7。 选择奇数的好处是,保持空间维度的同时,我们可以在顶部和底部填充相同数量的行,在左侧和右侧填充相同数量的列。
此外,使用奇数的核大小和填充大小也提供了书写上的便利。对于任何二维张量X,当满足: 1. 卷积核的大小是奇数; 2. 所有边的填充行数和列数相同; 3. 输出与输入具有相同高度和宽度 则可以得出:输出Y[i, j]是通过以输入X[i, j]为中心,与卷积核进行互相关计算得到的。
比如,在下面的例子中,我们创建一个高度和宽度为3的二维卷积层,并在所有侧边填充1个像素。给定高度和宽度为8的输入,则输出的高度和宽度也是8。
import torch
from torch import nn
# 为了方便起见,我们定义了一个计算卷积层的函数。
# 此函数初始化卷积层权重,并对输入和输出提高和缩减相应的维数
def comp_conv2d(conv2d, X):
# 这里的(1,1)表示批量大小和通道数都是1
X = X.reshape((1, 1) + X.shape) # 将输入张量 X 的形状从 (8, 8) 转换为 (1, 1, 8, 8)
Y = conv2d(X)
# 省略前两个维度:批量大小和通道
return Y.reshape(Y.shape[2:]) # 卷积后的输出张量 Y 的形状为 (1, 1, H, W),其中 H 和 W 是输出的高度和宽度。这行代码移除了前两个维度(批量大小和通道数),只保留卷积输出的空间尺寸 (H, W)。
# 请注意,这里每边都填充了1行或1列,因此总共添加了2行或2列
conv2d = nn.Conv2d(1, 1, kernel_size=3, padding=1)
X = torch.rand(size=(8, 8))
comp_conv2d(conv2d, X).shape
卷积核(kernel_size=3):卷积核的大小为3x3,表示卷积操作会应用一个3x3的矩阵来处理输入数据。
填充(padding=1):在进行卷积运算之前,输入的每一边都填充1行和1列,这样填充后输入数据的边界不会缩小。具体地,输入大小从 (8, 8) 变为 (8 + 2, 8 + 2) = (10, 10)。
通道数(in_channels=1 和 out_channels=1):卷积层输入和输出的通道数均为1,这意味着输入和输出数据都是二维的单通道图像。
Y.shape[2:] 表示获取张量 Y 的形状(shape)中从第 2 维开始的所有维度。
假设 Y 的形状是 (1, 1, H, W):
- Y.shape[0] 是批量大小(1)
- Y.shape[1] 是通道数(1)
- Y.shape[2] 是高度(H)
- Y.shape[3] 是宽度(W)
Y.shape[2:] 就是从第 2 维开始的部分,即 (H, W)。
在你的代码中,Y.reshape(Y.shape[2:]) 是将 Y 的形状从 (1, 1, H, W) 变为 (H, W),从而省略掉批量大小和通道数的维度,仅保留高度和宽度信息。
当卷积核的高度和宽度不同时,我们可以填充不同的高度和宽度,使输出和输入具有相同的高度和宽度。在如下示例中,我们使用高度为5,宽度为3的卷积核,高度和宽度两边的填充分别为2和1。
conv2d = nn.Conv2d(1, 1, kernel_size=(5, 3), padding=(2, 1))
comp_conv2d(conv2d, X).shape
建议这么理解8+2-(3-1)
卷积操作后输出的高度和宽度可以通过以下公式计算:
步幅
在计算互相关时,卷积窗口从输入张量的左上角开始,向下、向右滑动。 在前面的例子中,我们默认每次滑动一个元素。 但是,有时候为了高效计算或是缩减采样次数,卷积窗口可以跳过中间位置,每次滑动多个元素。
下面,我们将高度和宽度的步幅设置为2,从而将输入的高度和宽度减半。
conv2d = nn.Conv2d(1, 1, kernel_size=3, padding=1, stride=2)
comp_conv2d(conv2d, X).shape
接下来,看一个稍微复杂的例子。
conv2d = nn.Conv2d(1, 1, kernel_size=(3, 5), padding=(0, 1), stride=(3, 4))
comp_conv2d(conv2d, X).shape
总结:
- 填充和步幅是卷积层的超参数
- 填充在输入周围添加额外的行/列,来控制输出形状的减少量
- 步幅是每次滑动核窗口时的行/列的步长,可以成倍的减少输出形状
6.4 多输入多输出通道
多输入通道
当输入包含多个通道时,需要构造一个与输入数据具有相同输入通道数的卷积核,以便与输入数据进行互相关运算。
(注意这里的输出是单通道的,所以是结果相加)
为了加深理解,我们实现一下多输入通道互相关运算。 简而言之,我们所做的就是对每个通道执行互相关操作,然后将结果相加。
import torch
from d2l import torch as d2l
def corr2d_multi_in(X, K):
# 先遍历“X”和“K”的第0个维度(通道维度),再把它们加在一起
return sum(d2l.corr2d(x, k) for x, k in zip(X, K))
zip(X, K):X 和 K 都是多通道张量,因此 zip(X, K) 会将输入张量 X 的每个通道 x 与卷积核 K 的每个通道 k 配对,形成 (x, k) 的元组列表。
sum(d2l.corr2d(x, k) for x, k in zip(X, K)):
- 对于每一对 (x, k),执行 d2l.corr2d(x, k),即对输入通道 x 和对应的卷积核 k 进行二维互相关操作,得到一个二维的输出矩阵。
- 最后通过 sum(…) 将每个通道互相关的结果累加起来,得到最终的输出。这相当于对所有通道的卷积结果求和,生成一个单通道的输出。
假设输入 X 和卷积核 K 各有 3 个通道:
X: (C=3, H, W) K: (C=3, h, w)
对应的操作为:
- X 的每个通道与 K 的每个通道执行 d2l.corr2d(x, k),生成 3 个二维矩阵。
- 将这 3 个矩阵逐元素相加,得到一个最终的单通道输出矩阵。
我们可以构造与 图6.4.1中的值相对应的输入张量X和核张量K,以验证互相关运算的输出。
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)
多输出通道
到目前为止,不论有多少输入通道,我们还只有一个输出通道。然而,正如我们在 6.1.4.1节中所讨论的,每一层有多个输出通道是至关重要的。在最流行的神经网络架构中,随着神经网络层数的加深,我们常会增加输出通道的维数,通过减少空间分辨率以获得更大的通道深度。直观地说,我们可以将每个通道看作对不同特征的响应。而现实可能更为复杂一些,因为每个通道不是独立学习的,而是为了共同使用而优化的。因此,多输出通道并不仅是学习多个单通道的检测器。
对于每一个输入,把它对应的一个输出通道的核拿出来,就会得到一个对应的输出通道
如下所示,我们实现一个计算多个通道的输出的互相关函数。
def corr2d_multi_in_out(X, K):
# 迭代“K”的第0个维度,每次都对输入“X”执行互相关运算。
# 最后将所有结果都叠加在一起
return torch.stack([corr2d_multi_in(X, k) for k in K], 0)
通过将核张量K与K+1(K中每个元素加1)和K+2连接起来,构造了一个具有3个输出通道的卷积核。
K = torch.stack((K, K + 1, K + 2), 0)
K.shape
下面,我们对输入张量X与卷积核张量K执行互相关运算。现在的输出包含3个通道,第一个通道的结果与先前输入张量X和多输入单输出通道的结果一致。
corr2d_multi_in_out(X, K)
1*1卷积层
因为使用了最小窗口,11卷积失去了卷积层的特有能力——在高度和宽度维度上,识别相邻元素间相互作用的能力。 其实11卷积的唯一计算发生在通道上。
下面,我们使用全连接层实现1*1卷积。 请注意,我们需要对输入和输出的数据形状进行调整。
def corr2d_multi_in_out_1x1(X, K):
c_i, h, w = X.shape
c_o = K.shape[0]
X = X.reshape((c_i, h * w))
K = K.reshape((c_o, c_i))
# 全连接层中的矩阵乘法
Y = torch.matmul(K, X)
return Y.reshape((c_o, h, w))
当执行1*1卷积运算时,上述函数相当于先前实现的互相关函数corr2d_multi_in_out。让我们用一些样本数据来验证这一点。
X = torch.normal(0, 1, (3, 3, 3))
K = torch.normal(0, 1, (2, 3, 1, 1))
Y1 = corr2d_multi_in_out_1x1(X, K)
Y2 = corr2d_multi_in_out(X, K)
assert float(torch.abs(Y1 - Y2).sum()) < 1e-6
当以每像素为基础应用时,1*1卷积层相当于全连接层。
1*1卷积层通常用于调整网络层的通道数量和控制模型复杂性。
6.5 汇聚层
本节将介绍汇聚(pooling)层,它具有双重目的:降低卷积层对位置的敏感性(通常池化层作用在卷积层之后),同时降低对空间降采样表示的敏感性。
最大汇聚层和平均汇聚层
在下面的代码中的pool2d函数,我们实现汇聚层的前向传播。 这类似于 6.2节中的corr2d函数。 然而,这里我们没有卷积核,输出为输入中每个区域的最大值或平均值。
import torch
from torch import nn
from d2l import torch as d2l
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
我们可以构建 图6.5.1中的输入张量X,验证二维最大汇聚层的输出。
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))
此外,我们还可以验证平均汇聚层。
pool2d(X, (2, 2), 'avg')
填充和步幅
与卷积层一样,汇聚层也可以改变输出形状。和以前一样,我们可以通过填充和步幅以获得所需的输出形状。 下面,我们用深度学习框架中内置的二维最大汇聚层,来演示汇聚层中填充和步幅的使用。 我们首先构造了一个输入张量X,它有四个维度,其中样本数和通道数都是1。
X = torch.arange(16, dtype=torch.float32).reshape((1, 1, 4, 4))
X
默认情况下,深度学习框架中的步幅与汇聚窗口的大小相同。 因此,如果我们使用形状为(3, 3)的汇聚窗口,那么默认情况下,我们得到的步幅形状为(3, 3)。
pool2d = nn.MaxPool2d(3)
pool2d(X)
填充和步幅可以手动设定。
pool2d = nn.MaxPool2d(3, padding=1, stride=2)
pool2d(X)
当然,我们可以设定一个任意大小的矩形汇聚窗口,并分别设定填充和步幅的高度和宽度。
pool2d = nn.MaxPool2d((2, 3), stride=(2, 3), padding=(0, 1))
pool2d(X)
多个通道
在处理多通道输入数据时,汇聚层在每个输入通道上单独运算,而不是像卷积层一样在通道上对输入进行汇总。 这意味着汇聚层的输出通道数与输入通道数相同。 下面,我们将在通道维度上连结张量X和X + 1,以构建具有2个通道的输入。
X = torch.cat((X, X + 1), 1)
X
这行代码的目的是将张量 X 和 X + 1 沿指定的维度拼接起来。
- 张量 X
假设 X 是一个形状为 (batch_size, channels, height, width) 的四维张量,也就是说它是一个批量的多通道图像数据。 - X + 1
X + 1 表示对张量 X 中的每个元素加 1。假设 X 的形状是 (batch_size, channels, height, width),那么 X + 1 也是相同形状的张量,所有元素都增加了 1。 - torch.cat((X, X + 1), 1)
torch.cat 是 PyTorch 中用于沿指定维度拼接(concat)张量的函数。它的第一个参数是一个元组,包含要拼接的张量,第二个参数指定拼接的维度。
(X, X + 1):将原始张量 X 和 X + 1 拼接在一起。
1:指定拼接操作在第 1 维(即通道维度)进行。假设 X 的形状是 (batch_size, channels, height, width),那么这个操作会在通道维度上拼接两个张量。 - 示例
假设 X 的形状是 (batch_size=1, channels=3, height=8, width=8),即一张 8x8 大小、3 个通道的图像:
X + 1 也是形状为 (1, 3, 8, 8),但每个元素都比 X 的元素大 1。
torch.cat((X, X + 1), 1) 会将这两个形状相同的张量在通道维度拼接,生成一个形状为 (1, 6, 8, 8) 的新张量,通道数变为 6。
如下所示,汇聚后输出通道的数量仍然是2。
pool2d = nn.MaxPool2d(3, padding=1, stride=2)
pool2d(X)
6.6 卷积神经网络(LeNet)
通过之前几节,我们学习了构建一个完整卷积神经网络的所需组件。 回想一下,之前我们将softmax回归模型( 3.6节)和多层感知机模型( 4.2节)应用于Fashion-MNIST数据集中的服装图片。 为了能够应用softmax回归和多层感知机,我们首先将每个大小为28*28的图像展平为一个784维的固定长度的一维向量,然后用全连接层对其进行处理。 而现在,我们已经掌握了卷积层的处理方法,我们可以在图像中保留空间结构。 同时,用卷积层代替全连接层的另一个好处是:模型更简洁、所需的参数更少。
本节将介绍LeNet,它是最早发布的卷积神经网络之一,因其在计算机视觉任务中的高效性能而受到广泛关注。 这个模型是由AT&T贝尔实验室的研究员Yann LeCun在1989年提出的(并以其命名),目的是识别图像 (LeCun et al., 1998)中的手写数字。 当时,Yann LeCun发表了第一篇通过反向传播成功训练卷积神经网络的研究,这项工作代表了十多年来神经网络研究开发的成果。
当时,LeNet取得了与支持向量机(support vector machines)性能相媲美的成果,成为监督学习的主流方法。 LeNet被广泛用于自动取款机(ATM)机中,帮助识别处理支票的数字。 时至今日,一些自动取款机仍在运行Yann LeCun和他的同事Leon Bottou在上世纪90年代写的代码呢!
LeNet
总体来看,LeNet(LeNet-5)由两个部分组成:
- 卷积编码器:由两个卷积层组成;
- 全连接层密集块:由三个全连接层组成。
该架构如 图6.6.1所示。
- 先使用卷积层来学习图片空间信息
- 然后使用全连接层来转换到类别空间
每个卷积块中的基本单元是一个卷积层、一个sigmoid激活函数和平均汇聚层。请注意,虽然ReLU和最大汇聚层更有效,但它们在20世纪90年代还没有出现。每个卷积层使用5*5卷积核和一个sigmoid激活函数。这些层将输入映射到多个二维特征输出,通常同时增加通道的数量。第一卷积层有6个输出通道,而第二个卷积层有16个输出通道。每个2*2池操作(步幅2)通过空间下采样将维数减少4倍。卷积的输出形状由批量大小、通道数、高度、宽度决定。
为了将卷积块的输出传递给稠密块,我们必须在小批量中展平每个样本。换言之,我们将这个四维输入转换成全连接层所期望的二维输入。这里的二维表示的第一个维度索引小批量中的样本,第二个维度给出每个样本的平面向量表示。LeNet的稠密块有三个全连接层,分别有120、84和10个输出。因为我们在执行分类任务,所以输出层的10维对应于最后输出结果的数量。
通过下面的LeNet代码,可以看出用深度学习框架实现此类模型非常简单。我们只需要实例化一个Sequential块并将需要的层连接在一起。
import torch
from torch import nn
from d2l import torch as d2l
net = nn.Sequential(
nn.Conv2d(1, 6, kernel_size=5, padding=2), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2),
nn.Conv2d(6, 16, kernel_size=5), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2),
nn.Flatten(),
nn.Linear(16 * 5 * 5, 120), nn.Sigmoid(),
nn.Linear(120, 84), nn.Sigmoid(),
nn.Linear(84, 10))
nn.Flatten()是把 第0维的批量 保持住,后面全部拉成一个向量
我们对原始模型做了一点小改动,去掉了最后一层的高斯激活。除此之外,这个网络与最初的LeNet-5一致。
下面,我们将一个大小为28*28的单通道(黑白)图像通过LeNet。通过在每一层打印输出的形状,我们可以检查模型,以确保其操作与我们期望的 图6.6.2一致。
X = torch.rand(size=(1, 1, 28, 28), dtype=torch.float32)
# 万一搭错了,可以看到是自己哪一层输出错了
for layer in net:
X = layer(X)
print(layer.__class__.__name__,'output shape: \t',X.shape)
如果有几百层的话,pytorch的summary就不方便了
请注意,在整个卷积块中,与上一层相比,每一层特征的高度和宽度都减小了。 第一个卷积层使用2个像素的填充,来补偿5*5卷积核导致的特征减少。 相反,第二个卷积层没有填充,因此高度和宽度都减少了4个像素。 随着层叠的上升,通道的数量从输入时的1个,增加到第一个卷积层之后的6个,再到第二个卷积层之后的16个。 同时,每个汇聚层的高度和宽度都减半。最后,每个全连接层减少维数,最终输出一个维数与结果分类数相匹配的输出。
模型训练
现在我们已经实现了LeNet,让我们看看LeNet在Fashion-MNIST数据集上的表现。
batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size=batch_size)
虽然卷积神经网络的参数较少,但与深度的多层感知机相比,它们的计算成本仍然很高,因为每个参数都参与更多的乘法。 通过使用GPU,可以用它加快训练。
为了进行评估,我们需要对 3.6节中描述的evaluate_accuracy函数进行轻微的修改。 由于完整的数据集位于内存中,因此在模型使用GPU计算数据集之前,我们需要将其复制到显存中。
内存(RAM,Random Access Memory)是计算机中用于临时存储数据的存储空间。它通常被称为主存或系统内存**。CPU 主要使用内存来存储和处理数据。当你运行程序时,程序中的数据和指令会被加载到内存中,以便 CPU 快速读取和处理**。“完整的数据集位于内存中” 意思是数据集暂时存储在主机计算机的 RAM 中。
显存(VRAM,Video RAM)是专门为 GPU(图形处理单元)设计的高速存储空间。显存用于存储 GPU 需要处理的数据,比如神经网络模型的参数、输入数据、临时计算结果等。显存的容量一般比系统内存小(通常 4GB 到 24GB 或更多),但它的读写速度极快,以便 GPU 高效处理图形或并行计算任务(如深度学习)。“我们需要将其到显存中” 指的是在进行模型的评估时,我们需要将数据从主机的内存(RAM)转移到 GPU 的显存,这样 GPU 才能直接访问数据并进行高效计算。
def evaluate_accuracy_gpu(net, data_iter, device=None): #@save
"""使用GPU计算模型在数据集上的精度"""
if isinstance(net, nn.Module): # 检查 net 是否是 PyTorch 的 nn.Module 的子类
net.eval() # 设置为评估模式
# 如果没有提供 device 参数,函数会自动获取模型参数的设备
# 这样可以确保模型和数据在同一个设备上进行计算
if not device:
device = next(iter(net.parameters())).device
# 正确预测的数量,总预测的数量
metric = d2l.Accumulator(2)
with torch.no_grad(): # 在模型评估时禁用梯度计算,因为我们不需要进行反向传播,这样可以节省显存和计算资源
for X, y in data_iter: # 遍历数据集,data_iter 返回批量的输入 X 和标签 y
# 数据迁移到指定设备
if isinstance(X, list): # 如果 X 是一个列表
# BERT微调所需的(之后将介绍)
X = [x.to(device) for x in X] # 则需要将列表中的每个元素迁移到设备上
else: # 否则,直接将 X 和 y 迁移到设备上
X = X.to(device)
y = y.to(device)
metric.add(d2l.accuracy(net(X), y), y.numel())
return metric[0] / metric[1]
device = next(iter(net.parameters())).device
这行代码的作用是自动确定模型的设备(device),也就是模型参数所在的计算设备(通常是 GPU 或 CPU)。具体解释如下:
- net.parameters()
net.parameters() 是 PyTorch 中的一个方法,它返回一个迭代器,包含模型 net 的所有参数(例如权重和偏置)。这些参数通常是 torch.Tensor 对象,并且每个 Tensor 都存储在某个设备上(如 GPU 或 CPU)。 - iter(net.parameters())
iter() 将 net.parameters() 转换为一个迭代器。迭代器是一个对象,可以用于逐个访问模型的参数。 - next(iter(net.parameters()))
next() 函数从迭代器中获取第一个参数(即模型的一个 torch.Tensor 对象)。在这里,它获取了模型中的第一个参数张量。 - .device
device 是 torch.Tensor 的一个属性,用于指示该张量所在的设备。设备可以是 cpu 或者 cuda:0(表示 GPU)。
如果模型的参数存储在 GPU 上,device 会是类似 cuda:0 的值。
如果参数存储在 CPU 上,device 则会是 cpu。 - 总结
这行代码的作用是:
获取模型 net 的第一个参数张量。
获取该张量所在的设备(GPU 或 CPU)。
将该设备存储在变量 device 中。
这允许后续代码知道模型已经加载到哪个设备(如 GPU 或 CPU),从而在执行操作时可以将数据加载到相同的设备上,避免设备不一致导致的错误。
为了使用GPU,我们还需要一点小改动。 与 3.6节中定义的train_epoch_ch3不同,在进行正向和反向传播之前,我们需要将每一小批量数据移动到我们指定的设备(例如GPU)上。
如下所示,训练函数train_ch6也类似于 3.6节中定义的train_ch3。 由于我们将实现多层神经网络,因此我们将主要使用高级API。 以下训练函数假定从高级API创建的模型作为输入,并进行相应的优化。 我们使用在 4.8.2.2节中介绍的Xavier随机初始化模型参数。 与全连接层一样,我们使用交叉熵损失函数和小批量随机梯度下降。
#@save
def train_ch6(net, train_iter, test_iter, num_epochs, lr, device):
"""用GPU训练模型(在第六章定义)"""
def init_weights(m):
if type(m) == nn.Linear or type(m) == nn.Conv2d:
nn.init.xavier_uniform_(m.weight)
net.apply(init_weights)
print('training on', device)
net.to(device)
optimizer = torch.optim.SGD(net.parameters(), lr=lr)
loss = nn.CrossEntropyLoss()
animator = d2l.Animator(xlabel='epoch', xlim=[1, num_epochs],
legend=['train loss', 'train acc', 'test acc'])
timer, num_batches = d2l.Timer(), len(train_iter)
for epoch in range(num_epochs):
# 训练损失之和,训练准确率之和,样本数
metric = d2l.Accumulator(3)
net.train()
for i, (X, y) in enumerate(train_iter):
timer.start()
optimizer.zero_grad()
X, y = X.to(device), y.to(device)
y_hat = net(X)
l = loss(y_hat, y)
l.backward()
optimizer.step()
with torch.no_grad():
metric.add(l * X.shape[0], d2l.accuracy(y_hat, y), X.shape[0])
timer.stop()
train_l = metric[0] / metric[2]
train_acc = metric[1] / metric[2]
if (i + 1) % (num_batches // 5) == 0 or i == num_batches - 1:
animator.add(epoch + (i + 1) / num_batches,
(train_l, train_acc, None))
test_acc = evaluate_accuracy_gpu(net, test_iter)
animator.add(epoch + 1, (None, None, test_acc))
print(f'loss {train_l:.3f}, train acc {train_acc:.3f}, '
f'test acc {test_acc:.3f}')
print(f'{metric[2] * num_epochs / timer.sum():.1f} examples/sec '
f'on {str(device)}')
现在,我们训练和评估LeNet-5模型。
lr, num_epochs = 0.9, 10
train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())