目录
卷积层
卷积是一个特殊的全连接层
对全连接层使用
- 平移不变性
- 局部性
得到卷积层
二维交叉相关
平移不变性:kernel值不变
局部性:只看kernel的2 * 2的窗口里的数据
二维卷积层
★ 就是刚刚定义的二维交叉操作
神经网络可以通过去学习这样的一些核,来得到我想要的结果
交叉相关 VS 卷积
其实没太大差别,只是卷积公式多了两个负号,但是由于对称性,实际使用中没太大区别,而且W本来就是网络自己学出来的
为了方便,神经网络在公式表示方面没有采用标准的二维卷积公式,很多时候直接使用二位交叉相关公式
一维和三维交叉相关
一维
主要用于
- 文本
- 语言
- 时序序列
三维
主要用于:
- 视频
- 医学图像
- 气象地图
总结
- 卷积层将输入和核矩阵进行交叉相关,加上偏移后得到输出
- 核矩阵和偏移是可学习的参数
- 核矩阵的大小是超参数
代码实现
图像卷积
互相关运算
import torch
from torch import nn
from d2l import torch as d2l
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.shaoe[1]):
# 将[i: i + h, j: j + w] 与 k 做点积然后求和 --> 二维互相关的运算
Y[i, j] = (X[i: i + h, j: j + w] + k).sum()
return Y
实现二维卷积层
class Conv2D(nn.Module):
def __init__(self, kernel_size):
super().__init__()
self.weight = nn.Parameter(torch.rand(kernel_size))
self.bias = nn.Paramerter(torch.zeros(1))
def forward(self, x):
return corr2d(x, self.weight) + self.bias
简单应用
检测图像中不同颜色的边缘
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.]])
把上面数据可视化会在第二列和第三列中间有个白切黑的边缘,倒数第三列和倒数第二列中间有个黑切白的边缘。我们如何把它检测出来呢?
# 如果你两列是相同值的话输出就是0,如果两列不一样,输出要么是 1 要么是 -1
K = torch.tensor([[1.0, -1.0]])
输出 Y 中1表示从白色到黑色的边缘,-1 代表从黑色到白色的边缘
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.],])
这个卷积核K只能检测垂直边缘
# X.t():将 X 矩阵转置
corr2d(X.t(), K)
# 输出
tensor([[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., 0., 0., 0.],
[0., 0., 0., 0., 0.],])
学习由X生成Y的卷积核
# Conv2d(输入通道,输出通道,核矩阵,是否需要偏移)
conv2d = nn.Conv2d(1, 1, kernel_size = (1, 2), bias = False)
# 加两个维度(批量大小维度, 通道维度, 长, 宽)
# 对所有框架来说Conv2d的输入都是一个4d的东西
X = X.reshape((1, 1, 6, 8))
Y = Y.reshape((1, 1, 6, 7))
for i in range(10): # 迭代10轮
Y_hat = conv2d(X)
l = (Y_hat - Y)**2 # loss取一个均方误差
conv2d.zero_grad() # 梯度设0
l.sum().backward() # 求和之后算backward
# 一个裸写的梯度下降:学习率 * 梯度
conv2d.weight.data[:] -= 3e-2 * conv2d.weight.grad # 学习率:3e-2
if (i + 1) % 2 == 0: # 每2个batch来print以下loss
print(f'batch {i + 1}, loss {l.sum():.3f}')
# 输出
batch 2, loss 21.162
batch 4, loss 7.488
batch 6, loss 2.869
batch 8, loss 1.142
batch 10, loss 0.462
所学的卷积核的权重张量
conv2d.weight.dsta.reshape((1, 2))
# 输出
tensor([[0.9186, -1.0584]])
结果很接近我们一开始直接构造出来的[1, -1]了
填充和步幅
卷积层控制步幅大小的两个超参数
填充
假设我们给定(32 x 32)输入图像,在图像上应用 5 x 5 大小的卷积核
- 第一层得到输出大小 28 x 28
- 第七层得到输出大小 4 x 4
更大的卷积核可以更快地减小输出大小。但是如果我们卷积层数多了,比如例子中的第七层,我们就不能再愉快的卷积了 ——这时候就需要我们的填充!
在输入周围添加额外的行/列
填充 行和
列,输出形状为
通常取 =
-1 ,
=
- 1(好处是输出输入形状不会发生变化)
- 当
为奇数:在上下两侧填充
/2
- 当
为偶数:在上侧填充 [
/2](向上取整---多一行),在下侧填充 [
/2](向下取整---少一行)
我们核大小很少为偶数,基本都是1、3、5这种奇数
其实上下填充反过来也行,不会出现太大区别
我们一般填充都是选择 核大小 - 1,用来维持卷积之后数据形状大小不变
步幅
填充减小的输出大小与层数线性相关,当输入数据较大的时候,需要大量计算才能得到较小的输出——这时候我们就可以改变我们的步幅!!
步幅是指行/列的滑动步长
E.G.高度3 宽度2的步幅
怎么算呢?
可以理解为之前公式里面的 + 1其实加的就是步幅,只不过之前例子里面的步幅为 1,/1也没意义所以公式里面没有出现
通常来说步幅取 2,即每次减半
总结
- 填充和步幅是卷积层的超参数
- 填充在输入周围添加额外的行 / 列,来控制输出形状的减少量
- 步幅是每次滑动核窗口时的行 / 列的步长,可以成倍的减少输出形状
代码实现
在所有侧边填充一个像素
def comp_conv2d(conv2d, X):
# 在维度前面加入两个维度分别是批量大小数1,通道数1
X = X.reshape((1, 1) + X.shape)
Y = conv2d(X)
return Y.reshape(Y.shape[2:]) # 输出一个四维东西,拿掉前面两层可以看到后面输出的矩阵维度
# 这个padding指的是一侧(上or下or左or右),所以计算的时候应该是+2
conv2d = nn.Conv2d(1, 1, kernel_size = 3, padding = 1)
X = torch.rand(size = (8, 8))
comp_conv2d(conv2d, X).shape
# 输出
torch.Size(8, 8) # 8 - 3 + 2 + 1 = 8
填充不同的高度和宽度
# 核:行数5 列数3 填充:(仍想要形状不变)行数2 列数1
conv2d = nn.Conv2d(1, 1, kernel_size = (5, 3), padding = (2, 1))
comp_conv2d(conv2d, X).shape
# 输出
torch.Size([8, 8]) # 行:8 - 5 + 2*2 + 1 = 8 列:8 - 3 + 1 * 2 + 1 = 8
将高度核宽度的步幅设置为2
conv2d = nn.Conv2d(1, 1, kernel_size = 3, padding = 1, stride = 2)
comp_conv2d(conv2d, X).shape
# 输出
torch.Size([4, 4]) # (8 - 3 + 1 * 2 + 2) / 2 = 4
一个稍微复杂的例子
conv2d = nn.Conv2d(1, 1, kernel_size = (3, 5), padding = (0, 1), stride = (3, 4))
comp_conv2d(conv2d, X).shape
# 输出
# 除不开的向下取整
torch.Size([2, 2]) # 行:(8 - 3 + 0 * 2 + 3) / 3 = 2 列:(8 - 5 + 1 * 2 + 3) / 3 = 2
输入和输出通道
输入和输出通道其实是没有太多相关性在里面的
多个输入通道
每个通道都有一个卷积核,结果是所有通道卷积结果的和
C是channel(特征通道)的意思
多输入的二维卷积层
计算复杂度:你需要的浮点运算的程度
公式可以理解为:对于每一个输出(
)我都需要输入通道卷一下(
) ,输出通道要相加(
),卷积核里面整个都需要运算一下(
)
多个输出通道
上面的多输入通道,无论有多少个输入通道,目前为子我们只用到了单输出通道
如果想要一个多输出通道,我们可以有多个三维卷积核,每个核生成一个输出通道
输入没变,核从三维变成了多个三维核(即四维),输出由二维变成了多个二维(即多输出)
相当于
次卷积我完整的输入,得到
个输出,即为多维输出
那我们为什么需要多个输入和输出通道呢???
从单个层的角度
每一个输出通道在识别一个特定的模式
前面有讲过我们可以通过设置不同的核来生成我们需要锐化or边缘检测or模糊的效果。同样的反过来,我们可以通过去学习不同核的参数来匹配特定的模式。
举个例子:
假如我们的输入是一个猫的话, 上面六个通道分别表示了不同的特征,可能有颜色,有边缘纹理或者是单纯的一个横的边之类的。所以可以认为,每一个通道都是识别一个单独的模式把他输出出来。上面这个例子就是输出为6的例子。
每个输入通道核识别并组合输入中的模式
我们沿用上面小猫的例子,如果这六个通道作为输入传入下一层卷积,卷积核就可以组合这些特征,按照加权一相加,就能得到一种组合的模式识别。
从整个模型的角度
地下的一些层,就是识别一些局部的、底层的(各个角度的边啊、不同颜色的点啊)纹理特征;
越往上层,通过不断地把局部纹理特征组合起来,生成一些组合纹理(猫的胡须、猫的耳朵);
再往上层的有一些卷积层,就可能会有更宏观的特征(猫的头、猫的眼睛);
最后将所有这些东西组合起来,就变成了一只猫!
这是我们希望神经网络多多少少在干的事情,也就是多输入输出想法设计的出发点。
特殊的存在:1 x 1卷积层
=
= 1,是一个受欢迎的选择,它不识别空间模式(每次只看一个信息,不会去看这个信息的周围,去看它的空间模式),只是融合通道。
举一个例子:我有一个三维的3 x 3的输入数据,我希望通过1 x 1卷积得到二维的3 x 3输出数据
右侧红色点点的输出是由左侧三个红色点点和浅蓝色核分别相乘之后相加得到的;右侧绿色点点同理
为什么核形状是这样呢?
首先我们上面讲过,三维输出我们需要四维核,其中需要几维输出就需要用几个不同的核去卷我们一整个输入函数。所以例子中我们需要一个二维3 x 3,就需要两个不同的核对应我们不同维度的输出;几维的输入我们就需要几维的同一个核来分别卷积后相加。所以三维3 x 3的输入我们同一个核就需要三维。
所以1 x 1卷积相当于输入形状维 x
,权重为
x
的全连接
x
:相当于例子中把二维的3 x 3展成一维的9,乘以维度3
x
:将四维核的 1 x 1去掉变为一维
总结
-
输出通道数是卷积层的超参数
-
每个输入通道有独立的二维卷积核,所有通道结果相加得到一个输出通道的结果
-
每个输出通道有独立的三维卷积核
代码实现
多输入通道互相关运算
def corr2d_multi_in(X, K): # X:3d K:3d
return sum(coor2d(x, k) for x, k in zip(X, K))
""" zip():
将对象中对应的元素打包成一个个元组,然后返回由这些元组组成的列表
>>> a = [1,2,3]
>>> b = [4,5,6]
>>> c = [4,5,6,7,8]
>>> zipped = zip(a,b) # 返回一个对象
>>> zipped
<zip object at 0x103abc288>
>>> list(zipped) # list() 转换为列表
[(1, 4), (2, 5), (3, 6)]
>>> list(zip(a,c)) # 元素个数与最短的列表一致
[(1, 4), (2, 5), (3, 6)]
>>> a1, a2 = zip(*zip(a,b)) # 与 zip 相反,zip(*) 可理解为解压,返回二维矩阵式
>>> list(a1)
[1, 2, 3]
>>> list(a2)
[4, 5, 6]"""
验证互相关运算的输出
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_nulti_in(X, K)
# 输出
tensor([[56., 72.],
[104, 120.]])
多个通道的输出的互相关函数
def corr2d_multi_in_out(X, K): # X:3d K:4d,最外面的维度是输出通道
return torch.stack([corr2d_nulti_in(X, k) for k in K], 0)
K = torch.stack((K, K + 1, K + 2), 0)
K.shape
# 输出
torch.Size([3, 2, 2, 2])
corr2d_multi_in_out(X, K)
# 输出
tensor([[[56., 72.],
[104, 120]],
[[76., 100.],
[148., 172.]],
[[96., 128.],
[192., 224.]]])
torch.stack() VS torch.cat()
二者都是对tensor进行操作,区别在于stack()的拼接是生成新的维度-->两个二维张量stack成三维张量,两个三维张量stack成四维张量
cat()是直接在指定维度上融合-->两个二维张量合成还是二维张量
1 x 1 卷积
用MLP来实现1 x 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)) # 将三维向量拉成二维:主要是把 h 和 w 拉成一维
K = K.reshape((c_o, c_i)) # 把最后两个维度拿掉
Y = torch.matmal(K, X) # X(K的转置) 直接反过来写
return Y.reshape((c_o, h, w))
X = torch.normal(o, 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 # 做一个对比,需要预留一些浮点损失
QA
- 核大小、填充、步幅重要程度怎么排序
核大小一般是最重要的!填充一般取默认 核大小 -1。步幅是要看你最终要把模型复杂度控制在什么程度
- 为什么卷积核的边长一边取奇数
其实偶数效果来说不会有太大差别,之所以取奇数是为了对称一些,取偶数的话你上下填充就不对称了,会稍微奇怪一点。目前来说最多的卷积核就是 3 * 3