卷积神经网络基础
机器学习 · 动手深度学习。来自 Daily Learning 的学习笔记。
从全连接到卷积
MLP 的缺陷
1.参数太多
用 MLP 对猫狗的照片分类
- 手机像素约 12M
- RGB 图像 36M 元素
- 100 维度单隐藏层的 MPL 需要 参数
远多于世界上猫狗的数量
- 不如直接记住所有猫狗,不符合“特征提取”的理念
- 容易过拟合
- 训练开销过大
2.忽略空间结构
MLP 将图像展平为向量,忽略了像素间的远近、方向等,丢失信息
图像识别的关键特征
1.平移不变性
无论实体在图像中出现的位置如何,识别的 pattern 应该类似
卷积核参数共享
2.局部性
图像中相邻像素之间的相关性通常远大于较远像素之间,并且实体周边的局部信息往往是识别的关键
卷积核局部感受野
重新考察全连接层
二阶张量全连接变换
- 将输入和输出变为矩阵
- 权重变形为 4d 张量
[!TIP] 两种理解
- 棍到棍 2d 棍到面(多根棍)3d 面到面 4d
- 每个输入元素需要对于所有输出的权重(2d),输入有 2d 的元素,总共 的权重
推广到更高阶(只作为补充,下面用不到)
对于从 N 阶输入张量到 M 阶输出张量的完全稠密线性映射
- 权重张量 N+M 阶
- 偏置张量 M 阶
[!WARNING] 区分概念
- 维数 shape: 每个轴的长度
- 阶数 dim: 轴的数量
索引变化
类似于把 4d 张量变为 2d 张量
几何理解:
一个四维体 ,摊开为二维面 ,其中
- 视为一个大型二维面
- 其中从任意一点可建立一个小型二维面
- 此时对整个棋盘的 索引
- 先找到 面上坐标 的点
- 从 点建立小二维面 ,找其中相对坐标 的点
- 找到的 就是二维面 中的 元素
上面的索引中, 是大棋盘坐标, 是小棋盘坐标,因此 是展开平面中的真实坐标
原则 1: 平移不变性
的平移导致 的变化
根据平移不变性, 不应依赖于 ,即
这就是二维卷积(交叉相关)
几何理解:
分开理解大坐标 和小坐标
- 新权重张量 依赖于小坐标 ,即小棋盘中每个位置有独特权重
- 不依赖于大坐标 ,即在所有大棋盘中随便取一个小棋盘,整体权重分布是相同的
原则 2: 局部性
当评估 时,不应该使用远离 的参数
即当 时,令
*几何理解:
全图尺寸为 时,我们不需要 的权重张量,而只需要 的权重张量,即卷积核
最终结论
卷积层
二维卷积层
形状
- 输入 :
- 卷积核 :
- 偏置 : 标量
- 输出 :
计算
- 表示二维交叉操作(逐元素相乘求和)
- 是可学习参数
交叉相关 vs 卷积
- 二维交叉相关
- 二维卷积
卷积核翻转 180°,实际上不用考虑
一维与三维交叉相关
-
一维交叉相关
- 文本
- 时序序列
-
三维交叉相关
- 视频
- 医学图像
- 气象地图
图像卷积实现
import torch
from torch import nn
实现二维互相关计算
def corr2d(X, K):
"""计算二维互相关运算
@param X: 输入张量,二维
@param K: 卷积核,二维
@return: 输出张量,二维
"""
# 获取卷积核的高度和宽度
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]):
# 取输入张量中,以 (i, j) 为左上角,形状为 (h, w) 的区域,与卷积核进行逐元素相乘并求和
Y[i, j] = (X[i:i+h, j:j+w] * K).sum()
return Y
X = torch.tensor([[0.0, 1.0, 2.0],
[3.0, 4.0, 5.0],
[6.0, 7.0, 8.0]])
K = torch.tensor([[0.0, 1.0],
[2.0, 3.0]])
corr2d(X, K) # 输出张量
已保存输出
tensor([[19., 25.],
[37., 43.]])实现二维卷积层
class Conv2D(nn.Module):
"""二维卷积层
@param kernel_size: 卷积核的大小,整数或元组
"""
def __init__(self, kernel_size: int | tuple[int, int]):
super().__init__()
# 如果 kernel_size 是整数,则将其转换为元组 (kernel_size, kernel_size)
if isinstance(kernel_size, int):
kernel_size = (kernel_size, kernel_size)
# 初始化卷积核参数,形状为 (kernel_height, kernel_width)
self.weight = nn.Parameter(torch.rand(*kernel_size))
# 初始化偏置参数,标量
self.bias = nn.Parameter(torch.zeros(1))
def forward(self, X):
"""前向传播
@param X: 输入张量,二维
@return: 输出张量,二维
"""
return corr2d(X, self.weight) + self.bias
检测图像中不同颜色边缘
创建一个 6x8 的图像张量,左右为 1 表示白色,中间为 0 表示黑色
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.]])创建一个卷积核,检测颜色变化
K = torch.tensor([[1.0, -1.0]]) # 左右一致则为 0
Y 中的 1 表示白到黑,-1 表示黑到白,0 表示没有变化
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 只能检测垂直边缘
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.]])学习卷积核
# 输入通道数为 1,输出通道数为 1,卷积核大小为 (1, 2),不使用偏置
conv2d = nn.Conv2d(1, 1, kernel_size=(1, 2), bias=False)
# 给 X 和 Y 加入通道和批量维度
X = X.reshape((1, 1, 6, 8))
Y = Y.reshape((1, 1, 6, 7))
for i in range(10):
# 前向传播
Y_hat = conv2d(X)
# 平方和误差损失
l = ((Y_hat - Y) ** 2).sum()
# 梯度清零
conv2d.zero_grad()
# 反向传播
l.backward()
# SGD 更新参数
conv2d.weight.data[:] -= 3e-2 * conv2d.weight.grad
if (i + 1) % 2 == 0:
print(f'epoch {i + 1}, loss {l.item():.3f}')
已保存输出
epoch 2, loss 4.546 epoch 4, loss 0.997 epoch 6, loss 0.263 epoch 8, loss 0.083 epoch 10, loss 0.030
查看学到的卷积核权重,应该接近 (1, -1),即检测颜色变化
conv2d.weight.data.reshape(-1, 2) # 去掉没用的通道、批量维度
已保存输出
tensor([[ 0.9731, -1.0072]])
卷积层超参数
填充
之前提到输出形状是 ,即输出缩小了
- 层数越多,输出越被压缩
- 卷积核越大,输出缩小越快
因此在原始图像的外围填充额外的行/列(一般为全0)
- 填充 行/列,输出形状为
- 通常取 ,即输出形状为
- 为奇数,两侧各填充 行/列
- 为偶数,左/上填充 ,右/下填充
def comp_conv2d(conv2d, X):
"""计算卷积层输出"""
# 给 X 加入通道和批量维度
X = X.reshape((1, 1, *X.shape))
# 前向传播
Y = conv2d(X)
# 去掉通道和批量维度
return Y.reshape(Y.shape[2:])
# 创建一个填充的卷积层和一个不填充的卷积层
conv2d_nopad = nn.Conv2d(1, 1, kernel_size=3, padding=0)
conv2d_pad = nn.Conv2d(1, 1, kernel_size=3, padding=1)
X = torch.rand(size=(8, 8))
# 比较输出形状
comp_conv2d(conv2d_nopad, X).shape, comp_conv2d(conv2d_pad, X).shape
已保存输出
(torch.Size([6, 6]), torch.Size([8, 8]))
步幅
步幅指卷积核在行、列方向上每次移动的距离,即滑动步长
- 给定步幅 ,输出形状为
- 如果 ,输出形状为
- 如果 可被 整除,输出形状为
让图像高宽缩小到一半(步幅取 2,图像高宽是偶数)
conv2d = nn.Conv2d(1, 1, kernel_size=3, padding=1, stride=2)
X.shape, comp_conv2d(conv2d, X).shape
已保存输出
(torch.Size([8, 8]), torch.Size([4, 4]))
一个稍复杂的例子
一般不至于这样,通常用对称的输入
conv2d = nn.Conv2d(1, 1, kernel_size=(3, 5), padding=(0, 1), stride=(3, 4))
X.shape, comp_conv2d(conv2d, X).shape
已保存输出
(torch.Size([8, 8]), torch.Size([2, 2]))
通道
多输入通道
彩色图像有 RGB 3 个通道,每个通道拥有一个二维卷积核,将每个通道的卷积结果相加,构成三维卷积核
- 输入 :
- 卷积核 :
- 偏置 : 维向量
- 输出 :
多输出通道
可以有多个三维卷积核,每个卷积核产生一个输出通道,最终输出为多个通道的堆叠
- 输入 :
- 卷积核 :
- 偏置 :
- 输出 :
意义
- 每个输出通道可以识别特定模式(类似 transformer 的多个注意力头)
- 输入通道可以识别并组合输入中的模式
深度卷积神经网络中,通常先把 RGB 图像卷积到多个特征图,再把特征图卷积到更多的特征图,逐渐提取更高层次的特征
1x1 卷积层
是一个特殊的卷积层,它不识别空间模式,只是加权融合通道
相当于输入 输出 ,权重为 的全连接层,即每个像素点上的通道线性组合
实现多输入通道互相关运算
def corr2d_multi_in(X, K):
"""计算多输入通道的二维互相关运算
@param X: 输入张量,形状为 (c_i, n_h, n_w)
@param K: 卷积核,形状为 (c_i, k_h, k_w)
@return: 输出张量,二维
"""
# 对每个输入通道和卷积核通道进行互相关运算,并将结果相加
return sum(corr2d(x, k) for x, k in zip(X, K))
# 创建一个输入张量 X,形状为 (2, 3, 3),表示有 2 个输入通道,每个通道是 3x3 的矩阵
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,形状为 (2, 2, 2),表示有 2 个输入通道,每个通道是 2x2 的卷积核
K = torch.tensor([[[0.0, 1.0],
[2.0, 3.0]],
[[1.0, 2.0],
[3.0, 4.0]]])
corr2d_multi_in(X, K) # 输出张量
已保存输出
tensor([[ 56., 72.],
[104., 120.]])实现多输出通道互相关运算
def corr2d_multi_in_out(X: torch.Tensor, K: torch.Tensor):
"""计算多输入通道和多输出通道的二维互相关运算
@param X: 输入张量,形状为 (c_i, n_h, n_w)
@param K: 卷积核,形状为 (c_o, c_i, k_h, k_w)
@return: 输出张量,形状为 (c_o, m_h, m_w)
"""
# torch.stack 沿着新维度合并张量,dim 指定新维度插入的位置
return torch.stack([corr2d_multi_in(X, k) for k in K], dim=0)
# 创建一个卷积核张量 K,形状为 (3, 2, 2, 2),表示有 3 个输出通道,每个输出通道有 2 个输入通道,每个输入通道是 2x2 的卷积核
K4d = torch.stack((K, K + 1, K + 2), dim=0)
corr2d_multi_in_out(X, K4d) # 输出张量
已保存输出
tensor([[[ 56., 72.],
[104., 120.]],
[[ 76., 100.],
[148., 172.]],
[[ 96., 128.],
[192., 224.]]])</pre></details>
验证 1x1 卷积等价于全连接
代码 50 · In [38]
def corr2d_multi_in_out_1x1(X: torch.Tensor, K: torch.Tensor):
"""计算多输入通道和多输出通道的 1x1 卷积互相关运算
@param X: 输入张量,形状为 (c_i, n_h, n_w)
@param K: 卷积核,形状为 (c_o, c_i, 1, 1)
@return: 输出张量,形状为 (c_o, n_h, n_w)
"""
c_i, h, w = X.shape
c_o = K.shape[0]
# X 把图像拉成一维,因为每个像素点自己在多个通道组合,不考虑位置关系
X = X.reshape((c_i, h * w))
# K 把两个没用的维度去掉,方便矩阵乘法
K = K.reshape((c_o, c_i))
# 直接使用矩阵乘法计算输出,相当于每个像素点上做全连接
Y = torch.matmul(K, X)
# 将结果 reshape 回原来的形状
return Y.reshape((c_o, h, w))
代码 51 · In [50]
X = torch.normal(mean=0, std=1, size=(3, 3, 3)) # 输入张量,形状为 (3, 3, 3)
K = torch.normal(mean=0, std=1, size=(2, 3, 1, 1)) # 卷积核,形状为 (2, 3, 1, 1)
Y1 = corr2d_multi_in_out_1x1(X, K)
Y2 = corr2d_multi_in_out(X, K)
# 验证两个输出张量是否接近
torch.testing.assert_close(Y1, Y2)
池化层
原理
池化层通过在输入特征图上滑动固定大小的滑动窗口,对窗口内的元素进行聚合操作,生成更小的特征图,通常紧随卷积层之后。
类型
-
最大池化(Max Pooling): 窗口最大值作为输出,保留最强的模式信号
-
平均池化(Average Pooling): 窗口平均值作为输出,平滑特征,保留更多背景信息
作用
-
特征降维: 减少特征图尺寸,降低后续计算量和参数量
-
防止过拟合: 减少参数数量和模型复杂度,降低过拟合风险
-
缓解位置敏感性: 增强模型对平移、旋转和尺度变化的鲁棒性
超参数
- 窗口大小、步幅、填充都与卷积层类似
- 输入通道数 = 输出通道数
实现池化层的正向传播
def pool2d(x, pool_size, mode='max'):
"""计算二维池化运算
@param x: 输入张量,形状为 (n_h, n_w)
@param pool_size: 池化窗口大小,(h, w) 元组
@param mode: 池化模式,'max' 或 'avg'
@return: 输出张量,二维
"""
h, w = pool_size
# 按形状公式创建输出张量
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]):
# 取输入张量中,以 (i, j) 为左上角,形状为 (h, w) 的区域
if mode == 'max':
Y[i, j] = x[i:i+h, j:j+w].max()
elif mode == 'avg':
Y[i, j] = x[i:i+h, j:j+w].mean()
return Y
X = torch.tensor([[1.0, 2.0, 3.0],
[4.0, 5.0, 6.0],
[7.0, 8.0, 9.0]])
print(pool2d(X, (2, 2), 'max')) # 最大池化
print(pool2d(X, (2, 2), 'avg')) # 平均池化
已保存输出
tensor([[5., 6.],
[8., 9.]])
tensor([[3., 4.],
[6., 7.]])
pytorch 框架中默认步幅与窗口大小相同(窗口不重叠)
pool2d = nn.MaxPool2d(3)
X = torch.tensor([[1.0, 2.0, 3.0],
[4.0, 5.0, 6.0],
[7.0, 8.0, 9.0]])
# nn.MaxPool2d 接收 (N, C, H, W) 形状的输入张量,N 为批量大小,C 为通道数,H 为高度,W 为宽度
pool2d(X.reshape((1, 1, 3, 3))).reshape((1, 1))
已保存输出
tensor([[9.]])
设置一个形状莫名其妙的池化层
pool2d = nn.MaxPool2d((2, 3), padding=(1, 1), stride=(2, 3))
pool2d(X.reshape((1, 1, 3, 3))).reshape(-1, 1)
已保存输出
tensor([[2.],
[8.]])池化层在每个输入通道单独运算
X = torch.stack((X, X + 1), dim=0)
X
已保存输出
tensor([[[ 1., 2., 3.],
[ 4., 5., 6.],
[ 7., 8., 9.]],
[[ 2., 3., 4.],
[ 5., 6., 7.],
[ 8., 9., 10.]]])</pre></details>
代码 63 · In [76]
pool2d = nn.MaxPool2d(3, padding=1, stride=1)
pool2d(X)
已保存输出
tensor([[[ 5., 6., 6.],
[ 8., 9., 9.],
[ 8., 9., 9.]],
[[ 6., 7., 7.],
[ 9., 10., 10.],
[ 9., 10., 10.]]])</pre></details>