
Conv2d
手写 Conv2d 笔记
这份笔记对应代码文件:
目标是把下面几件事串起来理解:
Conv2d到底在算什么- 为什么深度学习里常用
B, C, H, W 1x1 Conv2d为什么能做通道变换- 通道注意力为什么经常会用到
1x1 Conv2d 卷积算子.py和cv通道注意力.py的区别是什么
1. Conv2d 的本质
卷积的核心可以概括成一句话:
取输入中的一小块区域,和卷积核逐元素相乘,再把结果加起来,得到一个输出值。
如果是二维卷积,那么输出特征图上的每一个位置,都是这样算出来的。
例如最普通的单通道 3x3 卷积:
输入局部区域:
1 2 3
4 5 6
7 8 9
卷积核:
1 0 1
0 1 0
1 0 1
那么当前位置的卷积结果就是:
1*1 + 2*0 + 3*1 +
4*0 + 5*1 + 6*0 +
7*1 + 8*0 + 9*1
2. PyTorch 常见的张量格式
在图像处理中,OpenCV 读出来的图片通常是:
H, W, C
含义是:
H: 高度W: 宽度C: 通道数
例如一张彩色图,形状可能是:
(480, 640, 3)
但是在深度学习里,尤其是 PyTorch,常见格式是:
B, C, H, W
含义是:
B: batch size,一次输入多少张图C: 通道数H: 高度W: 宽度
例如:
(1, 3, 480, 640)
表示:
- 1 张图
- 3 个通道
- 高 480
- 宽 640
在 这 里,做了下面这个变换:
img.astype(np.float32).transpose(2, 0, 1)[np.newaxis, ...]
它的作用就是把:
HWC -> CHW -> BCHW
3. 标准 Conv2d 中卷积核的形状
在深度学习里,卷积核不是简单的二维矩阵,而是四维:
(C_out, C_in, K_h, K_w)
其中:
C_out: 输出通道数C_in: 输入通道数K_h: 卷积核高度K_w: 卷积核宽度
比如:
weight.shape = (8, 3, 3, 3)
意思是:
- 有 8 个输出通道
- 每个输出通道都要同时看 3 个输入通道
- 每个卷积核大小是
3x3
所以,一个输出通道不是只看一个输入通道,而是会把所有输入通道的信息融合起来。
4. 手写 Conv2d 的核心逻辑
conv2d_numpy(x, weight, bias=None, stride=1, padding=0),本质是在手写 PyTorch 的 nn.Conv2d` 前向过程。
它的流程可以拆成下面几步。
4.1 先确定输入和卷积核形状
batch_size, in_channels, in_h, in_w = x.shape
out_channels, weight_channels, kernel_h, kernel_w = weight.shape
这一步是在把每个维度拆出来,方便后面算输出尺寸和遍历。
4.2 计算输出尺寸
公式是:
out_h = (in_h + 2 * pad_h - kernel_h) // stride_h + 1
out_w = (in_w + 2 * pad_w - kernel_w) // stride_w + 1
例如:
in_h = 8
kernel_h = 3
pad_h = 1
stride_h = 1
那么:
out_h = (8 + 2*1 - 3) // 1 + 1 = 8
4.3 对输入补零
x_pad = np.pad(...)
如果卷积核是 3x3,常见会设置 padding=1,这样输出高宽通常和输入一致。
4.4 在输出特征图上逐点计算
最核心的部分就是 4 层循环:
for b in range(batch_size):
for oc in range(out_channels):
for i in range(out_h):
for j in range(out_w):
含义分别是:
- 遍历第几张图
- 遍历第几个输出通道
- 遍历输出高方向的位置
- 遍历输出宽方向的位置
然后取出当前位置对应的输入局部区域:
region = x_pad[
b,
:,
row_start : row_start + kernel_h,
col_start : col_start + kernel_w,
]
这个 region 的形状是:
(C_in, K_h, K_w)
然后和第 oc 个卷积核做逐元素乘法,再求和:
value = np.sum(region * weight[oc])
最后得到当前输出位置的值:
out[b, oc, i, j] = value
所以公式可以写成:
out[b, oc, i, j] = sum(region * weight[oc]) + bias[oc]
如果没有 bias,那就不加偏置。
5. 什么是 1x1 Conv2d
如果卷积核大小是:
kernel_size = 1
那么卷积核形状就变成:
(C_out, C_in, 1, 1)
这时候它不会去看周围邻域,因为窗口大小只有 1x1。
它只会在当前位置上,把不同通道做线性组合。
公式会变成:
out[b, oc, i, j] = sum(x[b, ic, i, j] * weight[oc, ic, 0, 0])
注意这里没有看周围的 i-1, i+1, j-1, j+1,所以:
3x3卷积: 既看空间邻域,也混合通道1x1卷积: 不看空间邻域,只混合通道
6. 为什么 1x1 Conv2d 很适合做通道注意力
通道注意力关心的问题是:
哪些通道更重要,哪些通道应该被压低?
它关注的是“通道关系”,不是“空间邻域关系”。
所以经常会先把空间维度压掉,比如:
- 全局平均池化
- 全局最大池化
这样原来:
(B, C, H, W)
会被压成:
(B, C, 1, 1)
这时候再用 1x1 Conv2d,就很自然,因为它本质上就是在做通道变换。
在 通道注意力.py 里这部分写成:
self.fc1 = nn.Conv2d(in_channels, hidden_channels, kernel_size=1, bias=False)
self.fc2 = nn.Conv2d(hidden_channels, in_channels, kernel_size=1, bias=False)
虽然变量名叫 fc1、fc2,但实际实现用的是 1x1 Conv2d。
原因是:
- 输入仍然保持四维张量格式
- 写法更统一
- 和全连接层在
1x1情况下本质等价
7. 通道注意力的整体逻辑
标准通道注意力可以写成:
avg_out = MLP(AvgPool(x))
max_out = MLP(MaxPool(x))
attention = sigmoid(avg_out + max_out)
out = x * attention
含义是:
- 先对每个通道做全局池化,提取每个通道的整体响应
- 再经过一个小型 MLP,学习通道之间的关系
- 用
sigmoid生成0~1的权重 - 再把这些权重乘回原特征图
这样:
- 重要通道权重大
- 不重要通道权重小
8. 卷积算子.py 和 cv通道注意力.py 的区别
这两个文件都在做卷积相关的事情,但目标不一样。
8. 读这几份代码时的建议顺序
如果你现在正在学这一块,建议按这个顺序看:
- 先看
卷积算子.py先理解“卷积就是局部乘加” - 再看
cv通道注意力.py理解标准Conv2d的输入输出格式和多通道卷积 - 最后看
通道注意力.py理解为什么1x1 Conv2d可以拿来做通道注意力
这样会比较顺。
9. 一句话总结
- 普通卷积:提取局部空间特征
1x1卷积:做通道之间的线性组合- 通道注意力:给每个通道分配不同的重要性权重
如果把三者连起来理解,可以记成:
普通卷积负责“看哪里”,1x1 卷积负责“调哪些通道更重要”。