卷积网络与视觉表示
把一张 的灰度图展开,就得到 64 个数字。上一章的全连接网络已经能接收它们。那为什么还需要卷积网络?
因为图像中的位置有关系。相邻像素可能组成一条边,一条边平移几格后仍是一条边。全连接层可以从数据学习这些关系,卷积层则把“局部观察、重复使用同一套规则”的想法直接写进结构。
1. 拿一个小窗口,在图上移动
先看一张很简单的图:每行都是 ,一共五行。左侧暗、右侧亮,中间有一条竖直边界。
用下面的 窗口去观察它:
这个窗口叫卷积核。放到图像左上角,把覆盖的像素与核对应相乘,再全部相加,得到 3。向右移动一格,结果仍是 3;再移动一格,窗口里左右一样亮,结果变成 0。
图 1:核在左右亮度有差异的位置响应较大。这里的核是手工指定的,训练卷积网络时通常由数据学习。
对单输入、单输出通道,步长为 1、没有填充时:
是输入, 是输出特征图, 是核的高与宽, 是偏置。每个输出位置只看输入的一小块。
严格说,这个不翻转核的滑动点积在数学上叫互相关。深度学习框架通常沿用“卷积”的名称;本章实现与常用 Conv2d 接口一致。核可以学习时,是否翻转不改变这类结构能够表达的核集合,但对一个手工指定的核,计算约定要一致。
2. 同一组参数,为什么能在不同位置使用
如果每个位置都各学一套边缘规则,参数会很多,也可能只在见过的位置有效。卷积把同一套核用于所有位置,这叫参数共享。
图像平移时,在合适的边界条件下,步长为 1 的卷积响应也会相应平移,这叫平移等变。它不是“平移以后输出完全不变”:边缘从左边移动到右边,响应的位置也应移动。
如果希望分类结果对小幅移动较稳定,可以继续组合池化、空间汇总和数据增强。填充方式、下采样及边界裁剪都会影响这种性质,不能把“使用了卷积”直接等同于任意平移不变。
这一结构带入了一种先验:局部模式值得关注,相似模式在不同位置可能有相似作用。它适合很多视觉问题,却并非所有任务都适合。比如页面标题的位置本身可能有意义,完全忽略位置反而会丢掉信息。
3. 通道、步长与输出大小
灰度图可以看作一个通道;彩色图通常有三个颜色通道。一个输出通道的核会同时覆盖所有输入通道,将它们的贡献相加。多个输出通道可以学习不同模式。
若有 个输入通道、 个输出通道,核为 ,普通卷积层的参数数为:
最后的 1 对应每个输出通道的偏置。参数数不随图像位置数量直接增加,但在更大图上移动同一个核,计算量仍会增加。
步长决定每次移动几格。填充在图像周围补上额外位置,例如零值,使边缘也能参与计算,或维持输出大小。
不考虑膨胀卷积时,一个方向的大小为:
是输入大小, 是每侧填充量, 是核大小, 是步长;向下取整表示只保留完整窗口。本例 ,所以输出为 3。若填充 1、步长仍为 1,就能保持大小 5。
PyTorch 图像批次通常采用 :批量、通道、高、宽。不要把彩色通道误放到最后一维,却仍按这个约定调用卷积。
4. 池化与逐层扩大观察范围
最大池化从一个局部窗口中取最大值,例如 窗口、步长 2,将大致相邻的响应合成一个。它没有需要学习的权重,常用于缩小空间尺寸。
池化会丢失精确位置。一条细线可能在下采样后消失,几个不同图案也可能得到相同汇总结果。因此,缩小特征图既节省计算,也是一种信息取舍。
连续叠加卷积,会让后面的单元间接看见更大的输入区域,这个范围叫感受野。两个步长 1 的 卷积,在不考虑边界时,可使后层一个位置依赖原图的 区域。
早层常能学习局部边缘和纹理,后层则可能组合更复杂的模式。但不要把每个通道都机械命名为某个物体部件;具体表示要通过实验检查。
5. 残差连接:保留一条直接通路
网络越深,不代表越容易训练。除了继续改变初始化和优化器,还可以改变信息走过的路径。
残差块写成:
一条分支学习变换 ,另一条直接传递输入。网络可以先把 调得较小,让这一块接近恒等变换,再逐步学习需要的修正。
图 2:相加要求两条分支形状兼容。若通道数或空间大小改变,直接分支也需要相应投影或下采样。
在一维情况下,导数为 。多维时对应单位矩阵与变换的雅可比矩阵相加。直接通路有助于信号与梯度传播,但不保证永不消失或爆炸,因为两项可能抵消,其他层也仍会影响结果。
残差不是把旧输出保存下来、训练结束后再平均;它是每次前向计算中的结构,反向传播也会沿两条路径累计梯度。
6. 从窗口计算到可运行代码
示例脚本只使用标准库,演示一个通道、无填充的卷积与池化:
python3 src/examples/convolution.py
"""第 15 章:二维滑动点积、池化与残差;只用标准库。"""
def correlate(image, kernel, stride=1):
height, width = len(image), len(image[0])
kh, kw = len(kernel), len(kernel[0])
return [[sum(image[row+i][col+j]*kernel[i][j]
for i in range(kh) for j in range(kw))
for col in range(0, width-kw+1, stride)]
for row in range(0, height-kh+1, stride)]
def max_pool(image, size=2, stride=2):
return [[max(image[row+i][col+j] for i in range(size) for j in range(size))
for col in range(0, len(image[0])-size+1, stride)]
for row in range(0, len(image)-size+1, stride)]
def example():
image = [[0, 0, 1, 1, 1] for _ in range(5)]
kernel = [[-1, 0, 1] for _ in range(3)]
return image, kernel, correlate(image, kernel)
def main():
image, kernel, output = example()
print("原图的卷积输出:")
for row in output:
print(row)
shifted = [[0] + row[:-1] for row in image]
print("右移一格后,输出第一行:" + str(correlate(shifted, kernel)[0]))
print("2×2 最大池化:" + str(max_pool(output)))
values = [-2, 1]
print("残差 x+ReLU(x):" + str([x+max(0, x) for x in values]))
print(f"1 个输入通道、2 个输出通道、3×3 核的参数数:{2*(1*3*3+1)}")
if __name__ == "__main__":
main()
输出为:
原图的卷积输出:
[3, 3, 0]
[3, 3, 0]
[3, 3, 0]
右移一格后,输出第一行:[0, 3, 3]
2×2 最大池化:[[3]]
残差 x+ReLU(x):[-2, 2]
1 个输入通道、2 个输出通道、3×3 核的参数数:20
右移后的有效区域也发生了边界变化,因此不要把这两个小矩阵读成对任意大小、任意填充都成立的完整平移证明。它展示的是同一个局部模式移动后,响应位置跟着变化。
残差例子中的 是为了手算指定的。输入 −2 能沿直接分支保留下来,输入 1 则得到 2。真实残差块中的 通常包含可学习层,块末是否再加激活函数,也会影响最终输出。
第十六章的篇末实践会训练一个真正的图像分类器:卷积提取特征,池化缩小空间,再由全连接层输出十个数字类别的分数。这里先把一次卷积究竟算了什么看清楚。
7. 自测与小实验
- 本例核放在全为 1 的区域,为什么响应为 0?
- 一个核在图中滑动十个位置,会增加十套参数吗?
- 平移等变与平移不变有什么不同?
- 输入大小 8、核大小 3、每侧填充 1、步长 2,输出大小是多少?
- 输入通道 3、输出通道 8、核为 ,带偏置时有多少参数?
- 为什么残差相加前需要检查形状?
- 最大池化会保留窗口内所有像素的位置吗?
展开参考答案
- 每行 −1、0、1 的贡献相消,核检测的是左右差异。
- 不会,同一套核被重复使用;计算次数会增加。
- 等变是输入变化时输出对应变化;不变是输出保持相同。
- 为 。
- 为 。
- 逐项相加需要对应位置有一致含义,通常要求形状相同或经过明确设计的变换。
- 不会,它只保留最大值,丢掉了大量细节和精确位置信息。
选做:将卷积核转置,观察它更关注什么方向。再把图中竖直边界改成水平边界,检查猜测。不要只看总响应大小,也看响应发生在哪些位置。
下一章序列建模与表示学习将把“局部空间关系”换成“随时间到来的信息”。残差网络的原始工作是 He 等人的 Deep Residual Learning for Image Recognition;接口细节见 PyTorch Conv2d 文档。