在前面几篇笔记中,我们从零构建起了 CNN 并就卷积层的相关计算做出了拓展和延伸,而在 CNN 中,除了卷积层这一核心概念外还有一个类似的概念,我们称之为 汇聚层,或许这个名字看起来有些生僻,但如果提起另一个名字或许你就相当熟悉了——池化层(Pooling Layer)
池化层其实是 CNN “平移不变性”和“层次化抽象”的一个核心体现
池化
从技术上讲,汇聚层是一种下采样(Downsampling) 操作。它有一个类似卷积核的“窗口”,这个窗口在输入特征图上滑动。但它的任务不是做卷积计算,而是对窗口内的所有值进行一个简单的统计操作。
想象一件事:你正在看一幅非常高分辨率的风景照片(输入特征图)。现在,你想向别人描述这张照片的主要内容(例如:“照片左下角有一棵树,右上角有一座山”)。你不需要巨细无遗地描述每一片树叶和每一块石头的精确像素位置。相反,你会忽略一些细节,抓住核心的、显著的特征。这个“忽略细节,抓住核心”的过程,就是汇聚层在做的事情。
对于池化层,最常见的两种操作是:
- 最大汇聚(Max Pooling):取出窗口内的最大值。
- 平均汇聚(Average Pooling):计算窗口内所有值的平均值
而它的操作和卷积是十分类似的——
假设我们有一个4×4的输入特征图, values代表该位置特征的激活强度(例如,某个边缘的明显程度):
pytorch.tensor:
[[1, 1, 2, 4],
[5, 6, 7, 8],
[3, 2, 1, 0],
[1, 2, 3, 4]]
以大小为 $2\times 2$的池化(卷积)窗口 , $ Stride = 2 $进行最大汇聚:
- 第一个窗口(左上角)覆盖
[1,1;5,6]
,最大值为 6。 - 第二个窗口(右上角)覆盖
[2,4;7,8]
,最大值为 8。 - 第三个窗口(左下角)覆盖
[3,2;1,2]
,最大值为 3。 - 第四个窗口(右下角)覆盖
[1,0;3,4]
,最大值为 4。
此时,我们得到了一个 $2\times 2$ 的输出:[[6, 8], [3, 4]]
。可以看到,特征图的尺寸从4×4降到了2×2。
池化的作用
从上面的栗子过程来看,似乎池化层只是缩小了数据量,我们看不出有什么别的特征?
但事实上这正是平移不变性的实现手段:
我们的目标是无论目标在图像中的哪个位置发生了一点微小的移动(平移),网络最终都能识别出它。
仔细想想,最大汇聚只保留一个区域内的最强响应。只要这个最强特征(比如猫的眼睛)还在这个汇聚窗口内,无论它具体在窗口的左上角还是右下角,汇聚后的输出值都是一样的(都是那个最大值)。这就使得网络对目标位置的微小变化不再敏感。
这不正好满足了我们的目标嘛?只要一个物体是猫,那我干嘛管它在图片中是偏左一点还是偏右一点。
此外,通过下采样,汇聚层显著减小了特征图的空间尺寸(高度和宽度)。
这直接大幅减少了后续卷积层和全连接层的参数数量和计算成本,使得网络可以做得更深,同时缓解过拟合。
而在前文我们还提及了一个概念:感受野(Receptive Field)
其实我一直觉得叫感受域好听点 (๑•́ ₃ •̀๑)
每一层的神经元只能“看到”输入图像的一小部分区域,而池化层快速地将相邻神经元的信息进行聚合(通过取最大或平均),相当于放了个鹰眼术,让下一层的神经元能“看到”更广阔的原图区域。这对于整合上下文信息、理解更宏观的特征至关重要。
最后,池化层还有提取主要特征,抑制噪声的功能
- 最大汇聚:只保留最显著的特征,它假设最强的激活是最重要的特征,而忽略其他可能由噪声引起的微弱激活。这有利于纹理、边缘等特征的提取。
- 平均汇聚:对区域内的特征进行平滑,更能保留背景的整体信息,但对突出特征的强调不如最大汇聚明显。
实现
与卷积操作类似,但池化的实现会更简单,因为我们只需要知道窗口的大小而不关心窗口的内容。
import torch
import torch.nn.functional as F
def pooling_layer(X, kernel_size, pool_type='max', stride=1, padding=0):
# 对输入X进行对称填充
# F.pad参数:对于2D,填充格式为(left, right, top, bottom)
X_padded = F.pad(X, (padding, padding, padding, padding))
# 计算输出形状
H_out = (X.shape[0] - kernel_size[0] + 2 * padding) // stride + 1
W_out = (X.shape[1] - kernel_size[1] + 2 * padding) // stride + 1
Y = torch.zeros((H_out, W_out))
# 循环遍历输出位置的每个元素
for i in range(H_out):
for j in range(W_out):
# 计算窗口的起始和结束索引
h_start = i * stride
h_end = h_start + kernel_size[0]
w_start = j * stride
w_end = w_start + kernel_size[1]
# 从填充后的X中提取窗口
window = X_padded[h_start:h_end, w_start:w_end]
if pool_type == 'max':
Y[i, j] = torch.max(window)
elif pool_type == 'avg':
Y[i, j] = torch.mean(window)
return Y
多通道池化
由于池化操作不改变通道数,所以对于池化操作只有多输入通道的概念而没有多输出通道
那么对于多通道输入,池化操作就相当于对于每一个通道切片独立进行池化操作,逐通道(Channel-wise)独立
而在池化运算结束后,对于输入的每一个通道,都会产生一个对应的、经过下采样的输出通道。这些输出通道就是“分别输出”的
写在最后
考虑一个“奇怪”的现象,我们全程池化的操作只用了“平均”和“最大”两个概念。
那么为什么不考虑其他函数呢?
首先最小肯定是 pass 掉的,选择最小就意味着我们放弃了最显著的特征转而选择最大的噪声。
其实还是有其他方法的,比如:
L2范数汇聚(L2-Norm Pooling):计算窗口内所有值的平方和的平方根。公式
$$output = \sqrt{\sum_{i}^{}x_i^2}$$
- 它的值介于最大和平均之间。比平均汇聚对强特征更敏感,但又不像最大汇聚那样完全忽略其他信息。理论上可能更有表现力,但计算稍复杂,且效果提升不显著,未能取代最大汇聚。
随机汇聚(Stochastic Pooling):根据窗口内每个值的大小(值越大,概率越高)按多项式分布采样,选择一个值作为输出。
- 最大汇聚可能过拟合,平均汇聚可能太模糊。随机汇聚是一种正则化技术,通过引入随机性来防止过拟合,并可能提升模型的泛化能力。它在一些数据集上表现更好,但引入了不确定性,训练和推理行为不一致,增加了复杂度。
可学习的汇聚(Learnable Pooling):使用一个带步长(stride)的卷积层来替代固定的汇聚操作。例如,用一个步长为2的3×3卷积层。
- 这是现代架构(如ResNet)中最流行的“替代”方案。它不再是固定操作,而是通过反向传播学习如何下采样。它可以学会像最大汇聚一样保留强特征,或者学会更复杂的组合方式,灵活性远超任何固定函数的汇聚层。
全局平均汇聚(Global Average Pooling – GAP): 通过对整个特征图进行平均汇聚,将 [C, H, W]
的张量直接变为 [C, 1, 1]
的向量。
- 作用:常用在网络的最后,替代传统的全连接层,极大地减少了参数量,缓解过拟合,并且每个通道的输出可以直接代表对某个类别的置信度,使得网络更具可解释性。
但事实上这些函数很少被提及,究其原因主要还是深度学习中著名的“奥卡姆剃刀”原则——一个简单且有效的方法往往会击败一个复杂但只有边际提升的方法。
此外,现在的研究趋势是我们前面提及的以 Stride> 1 的卷积层来替代汇聚层。一个步长为2的卷积层,同样可以实现下采样(尺寸减半)的效果,同时它还在进行卷积运算(学习参数),而不是简单的固定操作。这被认为是一种更强大、更灵活的下采样方式。而且随着注意力机制(如SENet中的SE模块)、动态卷积等新技术的出现,研究界更关注如何让网络自适应地、智能地处理信息,而不是设计新的固定汇聚函数。这些新机制提供了比传统汇聚更精细的特征调控能力。
发表回复