查看全课大纲(第 11 / 40 节)
  1. 1.从规则到学习:深度学习解决什么问题
  2. 2.表示学习与端到端训练
  3. 3.张量、计算图与自动微分
  4. 4.训练三要素:数据、模型、目标函数
  5. 5.从感知机到多层感知机
  6. 6.反向传播:手推一个两层网络
  7. 7.激活函数与初始化
  8. 8.优化器:SGD → Momentum → Adam
  9. 9.正则化:权重衰减、Dropout、BN/LN
  10. 10.卷积的直觉与参数共享
  11. 11.池化、感受野与下采样
  12. 12.经典网络演进:LeNet → AlexNet → VGG → ResNet
  13. 13.残差连接与归一化为什么有效
  14. 14.迁移学习与特征可视化
  15. 15.RNN 与 BPTT
  16. 16.LSTM/GRU:门控如何缓解梯度问题
  17. 17.Seq2Seq 与编码器-解码器
  18. 18.注意力前史:Bahdanau 加性注意力
  19. 19.Self-Attention:QKV 与缩放点积
  20. 20.多头注意力:并行的多种关系
  21. 21.位置编码:正弦与可学习
  22. 22.残差、LayerNorm 与前馈层
  23. 23.从零实现一个最小 Transformer block
  24. 24.训练稳定性、复杂度与常见误区
  25. 25.自监督与语言建模目标
  26. 26.BERT:掩码语言模型与双向编码
  27. 27.GPT:自回归生成与缩放定律
  28. 28.指令微调与对齐:SFT → RLHF/DPO
  29. 29.参数高效微调:LoRA 与适配器
  30. 30.自编码器与 VAE
  31. 31.GAN:对抗训练与模式崩塌
  32. 32.扩散模型:前向加噪与反向去噪
  33. 33.扩散模型的应用与采样加速
  34. 34.对比学习与 CLIP
  35. 35.ViT:把图像当序列
  36. 36.多模态架构:对齐、融合与生成
  37. 37.能力边界、幻觉与评测
  38. 38.PyTorch 2.x 训练循环与混合精度
  39. 39.实验管理、评测与复现
  40. 40.推理部署与成本:量化/蒸馏概览

池化、感受野与下采样

池化、感受野与下采样:为什么卷积网络需要“降维”?

小象实战讲义 · 深度学习基础:从神经网络到 Transformer

上一节我们学习了卷积操作,它通过局部连接和权值共享,从图像中提取了丰富的局部特征。但随之而来的是特征图尺寸依然庞大,计算量和参数量依然很高。学完本节,你将能回答:为什么卷积神经网络(CNN)在卷积层之后通常紧跟着池化层?池化操作具体如何实现,它带来了哪些好处和代价?感受野是如何随着网络加深而增大的?理解这些机制,是掌握现代 CNN 架构(如 VGG、ResNet)的基础;后续在序列模型与 Transformer 里,你还会遇到「如何降低序列长度」的同类问题。

💡 核心导读

  1. 池化(Pooling)的本质:一种对局部区域进行“摘要”的下采样操作,最常用的是最大池化(取最大值)和平均池化(取平均值)。
  2. 池化的双重作用:降低特征图的空间尺寸(H, W),从而减少后续层的计算量和参数;同时引入一定的平移不变性,使网络对目标位置的微小变化不敏感。
  3. 感受野(Receptive Field)的计算:一个输出特征图上的像素点,其值由输入图像上多大区域内的像素计算而来。感受野随着卷积和池化层的堆叠而指数级增大,是网络能够“看到”全局信息的关键。
  4. 下采样(Downsampling)的实现方式:除了池化,带步长(stride>1)的卷积也可以直接实现下采样,这是现代网络设计中更常用的方式。

🧭 前置知识

  1. 二维卷积操作:理解卷积核在输入特征图上的滑动计算过程,包括填充(padding)和步长(stride)的概念。
  2. 张量形状:熟悉 (batch_size, channels, height, width) 的四维张量表示。
  3. 基本的Python和PyTorch操作:包括张量创建、索引和简单的数值计算。

池化操作:从局部到摘要

池化层通常紧跟在卷积层之后,它对每个特征图独立操作。假设我们有一个大小为 2x2,步长为2的池化窗口。

最大池化(Max Pooling):取窗口内的最大值。它更关注该区域内是否存在某个特征(如边缘、纹理),对噪声有一定的鲁棒性。 平均池化(Average Pooling):取窗口内的平均值。它更平滑,能保留背景信息。

让我们通过代码直观感受池化操作及其效果。

import torch
import torch.nn as nn

torch.manual_seed(0)  # 固定随机种子,保证结果可复现

# 模拟一个批大小为1,通道数为1,4x4的输入特征图
input_feature = torch.tensor([[[
    [1., 2., 5., 6.],
    [3., 4., 7., 8.],
    [9., 10., 13., 14.],
    [11., 12., 15., 16.]
]]])  # shape: (1, 1, 4, 4)

print("输入特征图 shape:", input_feature.shape)
print("输入特征图数据:\n", input_feature[0, 0])

# 定义最大池化层:核大小2x2,步长2
max_pool = nn.MaxPool2d(kernel_size=2, stride=2)
output_max = max_pool(input_feature)

# 定义平均池化层:核大小2x2,步长2
avg_pool = nn.AvgPool2d(kernel_size=2, stride=2)
output_avg = avg_pool(input_feature)

print("\n最大池化后 shape:", output_max.shape)
print("最大池化结果:\n", output_max[0, 0])

print("\n平均池化后 shape:", output_avg.shape)
print("平均池化结果:\n", output_avg[0, 0])
输入特征图 shape: torch.Size([1, 1, 4, 4])
输入特征图数据:
 tensor([[ 1.,  2.,  5.,  6.],
        [ 3.,  4.,  7.,  8.],
        [ 9., 10., 13., 14.],
        [11., 12., 15., 16.]])

最大池化后 shape: torch.Size([1, 1, 2, 2])
最大池化结果:
 tensor([[ 4.,  8.],
        [12., 16.]])

平均池化后 shape: torch.Size([1, 1, 2, 2])
平均池化结果:
 tensor([[ 2.5000,  6.5000],
        [10.5000, 14.5000]])

可以看到,4x4 的输入经过 2x2 池化后,空间尺寸减半为 2x2。最大池化输出了每个 2x2 窗口内的最大值,而平均池化输出了平均值。

感受野:网络“看到”了多大区域?

感受野是理解CNN层次化特征提取的核心概念。一个特征图上的像素点的感受野,是指输入图像上所有能影响该点取值的像素区域。

  • 第一层卷积:如果卷积核是 3x3,那么输出特征图上的每个点,其感受野就是输入图像上对应的 3x3 区域。
  • 叠加池化:如果第一层卷积后接一个 2x2 池化(步长2),那么第二层卷积(假设仍是 3x3)的一个输出点,它对应输入图像上多大区域?
    • 第二层卷积的一个点,对应第一层池化后特征图上的一个 3x3 区域。
    • 第一层池化后特征图上的一个点,对应第一层卷积后特征图上的一个 2x2 区域(因为池化)。
    • 第一层卷积后特征图上的一个点,对应输入图像上的一个 3x3 区域。
    • 这三层叠加后,第二层卷积的一个输出点覆盖输入图像 8x8 的区域——它恰好等于三个窗口尺寸直接相加(3+2+3=8),但这是本例步长组合下的巧合;换个 stride 就会算错,所以要用下面这种从后向前的递推方法。

更通用的计算方式是考虑从后向前递推。假设第 l 层特征图上某点的感受野大小为 RFlRF_l,该层卷积核大小为 KlK_l,步长为 SlS_l。那么,该点对应第 l-1 层特征图上的感受野大小 RFl−1RF_{l-1} 为: RFl−1=(RFl−1)×Sl+KlRF_{l-1} = (RF_l - 1) \times S_l + K_l 对于池化层,可以将其视为一种特殊的“卷积”操作(核大小为池化窗口,步长为池化步长),使用相同的公式。

让我们计算一个简单网络的感受野。

import torch
import torch.nn as nn

torch.manual_seed(0)

# 定义一个简单的网络:Conv1 -> Pool1 -> Conv2
class SimpleNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(1, 1, kernel_size=3, stride=1, padding=1)  # 保持尺寸
        self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2)
        self.conv2 = nn.Conv2d(1, 1, kernel_size=3, stride=1, padding=1)

    def forward(self, x):
        x = self.conv1(x)
        x = self.pool1(x)
        x = self.conv2(x)
        return x

model = SimpleNet()
# 创建一个虚拟输入
dummy_input = torch.randn(1, 1, 8, 8)
output = model(dummy_input)

print("输入图像尺寸 (H, W):", dummy_input.shape[2:])
print("输出特征图尺寸 (H, W):", output.shape[2:])

# 手动计算感受野
# 从输出层(conv2)开始往回推
# Conv2: K=3, S=1, padding=1 (不改变尺寸,不影响感受野计算)
# 假设输出特征图上一个点,对应pool1后特征图上的一个3x3区域。
# Pool1: K=2, S=2
# pool1后特征图上的一个点,对应conv1后特征图上的一个2x2区域。
# Conv1: K=3, S=1, padding=1
# conv1后特征图上的一个点,对应输入图像上的一个3x3区域。

# 计算:从输出点开始
rf = 1  # 输出点自身的感受野(就是它自己)
# 经过conv2
rf = (rf - 1) * 1 + 3  # rf = (1-1)*1 + 3 = 3
# 经过pool1
rf = (rf - 1) * 2 + 2  # rf = (3-1)*2 + 2 = 6
# 经过conv1
rf = (rf - 1) * 1 + 3  # rf = (6-1)*1 + 3 = 8

print("\n输出特征图上一个点的感受野(相对于输入图像):", rf)
print("这意味着,输出图上的一个点,是由输入图像上 8x8 区域内的信息计算得出的。")
输入图像尺寸 (H, W): torch.Size([8, 8])
输出特征图尺寸 (H, W): torch.Size([4, 4])

输出特征图上一个点的感受野(相对于输入图像): 8
这意味着,输出图上的一个点,是由输入图像上 8x8 区域内的信息计算得出的。

尽管我们的网络只有两层卷积和一层池化,但输出特征图上一个点的感受野已经覆盖了整个输入图像(8x8)。这就是深度网络能够整合全局信息、理解复杂模式的基础。

下采样的现代实现:带步长的卷积

早期网络如 LeNet 用池化层做下采样;VGG 也一样——它的全部下采样都由 2×2 最大池化完成,卷积层清一色 stride=1。真正把带步长(stride > 1)的卷积当成主力下采样手段的是 ResNet 及之后的网络:ResNet 在阶段之间用 stride=2 的 3×3 卷积(配合 1×1 投影的快捷连接)一次性完成「特征提取 + 尺寸减半」,比「卷积再池化」少一层、参数也更省。

import torch
import torch.nn as nn

torch.manual_seed(0)

# 模拟输入
input_tensor = torch.randn(1, 3, 8, 8)  # (batch, channels, height, width)
print("输入张量 shape:", input_tensor.shape)

# 方式1:传统方式(卷积+池化)
conv1 = nn.Conv2d(3, 6, kernel_size=3, stride=1, padding=1)  # 输出 (1, 6, 8, 8)
pool1 = nn.MaxPool2d(kernel_size=2, stride=2)                # 输出 (1, 6, 4, 4)
output_traditional = pool1(conv1(input_tensor))
print("传统方式(Conv stride=1 + Pool)输出 shape:", output_traditional.shape)

# 方式2:现代方式(带步长的卷积)
conv2 = nn.Conv2d(3, 6, kernel_size=3, stride=2, padding=1)  # 直接下采样
output_modern = conv2(input_tensor)
print("现代方式(Conv stride=2)输出 shape:", output_modern.shape)

# 比较参数量
print("\n参数量比较:")
print(f"Conv stride=1 参数量: {sum(p.numel() for p in conv1.parameters())}")
print(f"Conv stride=2 参数量: {sum(p.numel() for p in conv2.parameters())}")
print("两者参数量相同,因为卷积核的尺寸和输入/输出通道数相同。")
输入张量 shape: torch.Size([1, 3, 8, 8])
传统方式(Conv stride=1 + Pool)输出 shape: torch.Size([1, 6, 4, 4])
现代方式(Conv stride=2)输出 shape: torch.Size([1, 6, 4, 4])

参数量比较:
Conv stride=1 参数量: 168
Conv stride=2 参数量: 168
两者参数量相同,因为卷积核的尺寸和输入/输出通道数相同。

两种方式都将空间尺寸从 8x8 降到了 4x4。带步长的卷积一步到位,网络更简洁。在反向传播时,池化层(尤其是最大池化)需要记录最大值的位置,而带步长的卷积则统一用卷积的求导规则。

📝 动手练一练

题目:假设我们有一个输入图像尺寸为 224x224,依次经过以下层:

  1. Conv1: kernel_size=7, stride=2, padding=3
  2. Pool1: kernel_size=3, stride=2, padding=1
  3. Conv2: kernel_size=3, stride=1, padding=1
  4. Conv3: kernel_size=3, stride=2, padding=1

请计算经过每一层后特征图的尺寸,并计算最终输出特征图上一个点的感受野(相对于输入图像)。

👉 点击查看参考答案

思路:特征图尺寸计算公式为:Hout=⌊Hin+2×padding−Kstride⌋+1H_{out} = \lfloor \frac{H_{in} + 2 \times padding - K}{stride} \rfloor + 1,其中 KK 是卷积核边长。感受野使用递推公式 RFl−1=(RFl−1)×Sl+KlRF_{l-1} = (RF_l - 1) \times S_l + K_l 从后向前计算。

import math

def output_size(H_in, padding, kernel_size, stride):
    return math.floor((H_in + 2*padding - kernel_size) / stride) + 1

H_in = 224
layers = [
    {'name':'Conv1', 'K':7, 'S':2, 'P':3},
    {'name':'Pool1', 'K':3, 'S':2, 'P':1},
    {'name':'Conv2', 'K':3, 'S':1, 'P':1},
    {'name':'Conv3', 'K':3, 'S':2, 'P':1},
]

H = H_in
print("特征图尺寸变化:")
for layer in layers:
    H = output_size(H, layer['P'], layer['K'], layer['S'])
    print(f"{layer['name']} 后: {H}x{H}")

# 计算感受野
rf = 1
for layer in reversed(layers):
    rf = (rf - 1) * layer['S'] + layer['K']
print(f"\n最终输出特征图上一个点的感受野: {rf}x{rf}")
特征图尺寸变化:
Conv1 后: 112x112
Pool1 后: 56x56
Conv2 后: 56x56
Conv3 后: 28x28

最终输出特征图上一个点的感受野: 27x27

常见错误

错误理解或写法现象或报错原因正确做法
混淆池化与卷积的步长作用设计网络时,期望下采样但忘记设置 stride>1 或添加池化层,导致特征图尺寸不降反增(如果通道数增加),计算量爆炸。没有明确区分卷积的“特征提取”和池化的“降维摘要”功能,或忽略了步长的下采样能力。明确每一层的目的。若需下采样,在卷积层设置 stride>1,或在其后添加池化层。规划好网络各层的输入输出尺寸。
最大池化的反向传播误解自定义池化层反向传播时,梯度只回传到前向传播中被选为最大值的位置,其他位置梯度为0。若实现错误,会导致梯度消失或爆炸。不理解最大池化在前向时记录了最大值位置(索引),反向时需根据索引将梯度“散射”回正确位置。使用 torch.nn.MaxPool2d 等内置层,它们已正确实现。若需手写,务必使用 torch.max 返回索引,并用 torch.scatter 或索引赋值进行梯度回传。
感受野计算时把 padding 也算进公式算出的感受野偏大或偏小,与实测对不上。感受野大小的递推公式 RFl−1=(RFl−1)×Sl+KlRF_{l-1} = (RF_l - 1) \times S_l + K_l 与 padding 无关:padding 只改变特征图尺寸和感受野中心的位置,不改变窗口覆盖的范围。直接用上面的公式从后往前推;只有在讨论「边界处的感受野被裁掉多少」时才需要单独考虑 padding。

本章小结

  1. 池化层(最大/平均) 是一种下采样操作:最大池化是非线性聚合(取窗口最大值,输出不可由输入的线性组合得到),平均池化则是线性聚合(窗口内加权求均值)。两者都降低特征图空间维度、减少计算量,并引入一定的平移不变性。
  2. 感受野 定量描述了网络中特征点的“视野”范围,它随着卷积和池化层的堆叠而迅速增大,是网络具备层次化特征理解能力的基础。
  3. 下采样 不仅可以通过池化实现,ResNet 之后的网络更常用带步长(stride>1)的卷积同时完成特征提取和尺寸缩减,使网络设计更灵活。
  4. 池化操作(尤其是最大池化)在反向传播时需要特殊处理,只将梯度传播到前向传播中被选中的位置。

📋 行动清单

  • 修改第一个代码示例中的输入张量数值,观察最大池化和平均池化结果的不同,思考哪种情况更适合用最大池化。
  • 在“感受野计算”的代码示例中,尝试去掉 pool1 层,重新计算感受野,理解池化对扩大感受野的关键作用。
  • 查阅PyTorch官方文档中 nn.Conv2d 和 nn.MaxPool2d 的参数说明,重点关注 stride, padding, dilation 对输出形状的影响。

延伸阅读

本段 PyTorch 官方文档/教程条目的框架出处:PyTorch: An Imperative Style, High-Performance Deep Learning Library(Paszke et al., 2019,arXiv:1912.01703);页面内容以 PyTorch 2.14.1 为准。

  1. PyTorch 官方文档 torch.nn.MaxPool2d(2.14.1,窗口、步长与 dilation 对输出的影响) - https://pytorch.org/docs/stable/generated/torch.nn.MaxPool2d.html
  2. PyTorch 官方文档 torch.nn.Conv2d(2.14.1,含输出尺寸公式) - https://pytorch.org/docs/stable/generated/torch.nn.Conv2d.html
  3. ImageNet Classification with Deep Convolutional Neural Networks(Krizhevsky, Sutskever & Hinton, 2012, NIPS)—— AlexNet 中使用了重叠最大池化(kernel_size=3, stride=2),对后续网络影响深远 - https://papers.nips.cc/paper_files/paper/2012/hash/c399862d3b9d6b76c8436e924a68c45b-Abstract.html

—— 小象教研组