这一章的目标
理解卷积层(Conv2d) 做了什么 —— 为什么它比全连接层更适合处理图像
理解池化层(MaxPool2d) 的作用 —— 为什么要把图片"缩小"
搭一个完整的 CNN 网络 用于 MNIST 识别,并测试它的输出是否正常
第 4 章我们用了全连接层(nn.Linear)搭了一个网络。但你知道输入 nn.Linear(784, ...) 之前要做什么吗?要把 28×28 的图 "拉平" 成 784 个像素。
这个操作有一个致命问题:空间信息丢失了。
举例说明:下面这两张图在"拉平"之后,像素序列完全不一样,但图像内容其实是同一个字母 0 左右反转了一下(参考 show_mnist_flip.py 代码)。

对于人类来说,我们看一张图不会一个像素一个像素看,而是看局部特征:这里有边、那里有角、这里有个圈……
卷积层就是模拟这种 "局部感知" 的方式。它用一个小窗口(称为卷积核/filter)在图片上滑动,每次只看一小块区域,提取局部特征。如下图:

上图演示卷积神经网络里卷积层的核心计算过程,也就是卷积核(Filter/Kernel)在图像上滑动做计算,生成输出特征图(Output Array / Feature Map)。其中:
Input Image(左侧:输入图像矩阵):是原始图像像素矩阵。图中浅蓝色高亮框,代表卷积核当前覆盖住的3×3 局部图像窗口,取窗口内 9 个像素。
Filter(中间:卷积核 / 过滤器,3×3 权重矩阵):这是一组可学习的权重,用来检测图像特征(边缘、线条等)。卷积核会在整张图像上滑动。注意,PyTorch/Tensorflow 里面做的不是严格数学卷积,是互相关。卷积核不旋转翻转,直接对应位置相乘求和,工程上都叫“卷积”。
Output Array(右侧:输出特征图):每一个格子,是输入局部窗口和卷积核计算得到的结果。图中蓝色方块16就是输出的第一个值 Output[0][0]。
📌 术语:卷积(Convolution)
你不用管数学上的 "卷积" 是什么。在深度学习中,"卷积" 就是一个小窗口在图片上滑动,逐位置做加权求和的过程。
比如说一个 3×3 的卷积核,每次看 9 个像素,计算它们加权求和的结果作为输出。滑动完整个图片,就得到了 "特征图(feature map)"。
假设有一个 3×3 的卷积核,专门用来检测竖线。它的权重大概长这样:
-1 0 1
-1 0 1
-1 0 1把它在图片上滑动。当它遇到一条竖线(左边暗、右边亮)时,加权求和的结果会是一个很大的数。遇到其他地方,结果会接近于 0(运行 conv2d_demo.py 示例代码,演示卷积运算)。
这就叫 "这个卷积核激活了"。不同的卷积核能检测不同的特征:横线、斜线、拐角、圆等等。一层网络通常有多个卷积核(比如 32 个),每个学习检测不同的特征。
用 nn.Conv2d 定义卷积层。它的参数:
nn.Conv2d(
in_channels=1, # 输入通道数(灰度图=1,RGB图=3)
out_channels=32, # 输出通道数 = 卷积核数量(学32种特征)
kernel_size=3, # 卷积核大小(3×3)
stride=1, # 滑动步长
padding=1 # 边缘填充(保持输出尺寸不变)
)我们自己验证一下:
import torch
import torch.nn as nn
# 构造二维卷积层对象
# in_channels=1:输入通道数,MNIST灰度图只有1个通道
# out_channels=32:输出32个卷积核,学习提取32种不同图像特征(边缘、纹理等)
# kernel_size=3:卷积核大小3×3
# padding=1:图像四周填充1圈0像素;目的:卷积之后高、宽保持和输入不变
conv = nn.Conv2d(in_channels=1, out_channels=32, kernel_size=3, padding=1)
# MNIST 图片张量格式标准:(batch, channel, height, width)
# dummy_input:构造虚拟测试输入张量
# batch=1:1个样本;channel=1灰度通道;H=28,W=28 MNIST图像原始分辨率
dummy_input = torch.randn(1, 1, 28, 28) # 1张图 × 1通道 × 28×28
# 执行卷积前向计算,虚拟图片送入卷积层
output = conv(dummy_input)
# 打印输入张量维度
print(f"输入形状: {dummy_input.shape}") # → (1, 1, 28, 28)
# batch不变;通道变为32(32个卷积核各输出一路特征图);高宽维持28×28,padding=1带来的效果
print(f"输出形状: {output.shape}") # → (1, 32, 28, 28)
print(f"\n卷积核权重weight形状: {conv.weight.shape}") # [out_channels,in_channels,kH,kW] →(32,1,3,3)
print(f"偏置bias形状: {conv.bias.shape}") # 每个输出通道配有一个偏置 (32,)运行代码,输出如下:
输入形状: torch.Size([1, 1, 28, 28])
输出形状: torch.Size([1, 32, 28, 28])
卷积核权重weight形状: torch.Size([32, 1, 3, 3])
偏置bias形状: torch.Size([32])📌 术语:通道(Channel)
输入图片的 "通道":灰度图是 1,RGB 彩图是 3(红绿蓝各一个通道)
卷积层输出的 "通道":等于卷积核数量,每个通道是一张 "特征图"
下一层卷积的输入通道必须等于上一层卷积的输出通道
池化层(Pooling)的作用是减小特征图的尺寸,保留最重要的信息。
最常用的是 最大池化(MaxPool2d):在一个小窗口(比如 2×2)里,只保留最大值,扔掉其他的。
这样做的三个好处:
减少计算量:图变小了,后面层的计算量就少了
防止过拟合:扔掉细节,模型被迫关注最重要的特征
获得平移不变性:数字往左移一点或往右移一点,池化后的结果差不多
示例代码:
import torch
import torch.nn as nn
# 定义最大池化层
# kernel_size=2:池化窗口大小为2×2
# 默认 stride=kernel_size 即 stride=2,窗口每次滑动2格,不重叠
# 计算逻辑:在每一个2×2小窗口内,只保留窗口中最大的那个数值,丢弃其余数值
pool = nn.MaxPool2d(kernel_size=2) # 2×2 窗口,取最大值
# 输入:卷积层输出得到的特征图
# shape: [batch, channels, height, width]
# batch=1,通道32,高28,宽28,和上一节Conv2d输出结果保持一致
dummy_input = torch.randn(1, 32, 28, 28)
# 执行池化前向传播:特征图送入MaxPool2d层做下采样
output = pool(dummy_input)
# 打印池化之前张量维度
print(f"池化前形状: {dummy_input.shape}") # → (1, 32, 28, 28)
# 经过2×2最大池化、stride=2:高宽两个维度都除以2;通道数、batch数量保持不变
print(f"池化后形状: {output.shape}") # → (1, 32, 14, 14)
# 尺寸减半 ↑注意,2×2 的最大池化会让图片的宽和高各减半。
运行代码,输出如下:
池化前形状: torch.Size([1, 32, 28, 28])
池化后形状: torch.Size([1, 32, 14, 14])现在我们把卷积层(Conv2d)、池化层(MaxPool2d)、全连接层(Linear)组合起来,搭一个真正能用的 MNIST 识别网络。
神经网络结构设计思路:
输入 (1, 28, 28)
↓
Conv1: 1→32 通道, 3×3卷积 → (32, 28, 28) 提取低级特征(边、角)
↓ ReLU
Pool1: 2×2 最大池化 → (32, 14, 14) 尺寸减半
↓
Conv2: 32→64 通道, 3×3卷积 → (64, 14, 14) 提取高级特征(形状、部件)
↓ ReLU
Pool2: 2×2 最大池化 → (64, 7, 7) 尺寸再减半
↓
Flatten(拉平) → (64×7×7=3136) 展成一维
↓
FC1: 3136 → 128 → (128) 全连接分类
↓ ReLU
FC2: 128 → 10 → (10) 输出10个得分说明:
Conv 卷积层:3×3+padding=1,不缩小尺寸;通道数逐步升高(1→32→64):浅层少通道抓简单边缘纹理,深层多通道捕捉数字局部形状。
ReLU 激活:引入非线性,网络可以学习复杂模式;去掉 ReLU 网络等价线性模型。
MaxPool 最大池化:2×2 stride=2,宽高直接折半。减少参数量、抑制过拟合,让特征对微小位置偏移有一定鲁棒性。
Flatten 展平:卷积输出是多维特征图,全连接只能接收一维向量,所以把通道、高、宽合并,batch 维度不动。
两层全连接:第一层降维到 128 做特征融合,第二层输出 10 个类别得分;训练时搭配CrossEntropyLoss,内部自带 softmax,不用手动写 softmax。
代码实现如下:
import torch
import torch.nn as nn
class MNIST_CNN(nn.Module):
"""用于 MNIST 手写数字识别的简单 CNN 网络"""
def __init__(self):
super().__init__()
# ── 特征提取部分(卷积层) ──
# 第1组:卷积 + 池化
self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1) # 输入1通道→32特征
self.pool1 = nn.MaxPool2d(2, 2) # 28×28 → 14×14
# 第2组:卷积 + 池化
self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) # 32特征→64特征
self.pool2 = nn.MaxPool2d(2, 2) # 14×14 → 7×7
# ── 分类部分(全连接层) ──
self.fc1 = nn.Linear(64 * 7 * 7, 128) # 特征图展平 → 128维
self.fc2 = nn.Linear(128, 10) # 128维 → 10个类别
def forward(self, x):
"""前向传播:数据在网络中流动的路径"""
# 特征提取
x = torch.relu(self.conv1(x)) # Conv1 + ReLU
x = self.pool1(x) # Pool1
x = torch.relu(self.conv2(x)) # Conv2 + ReLU
x = self.pool2(x) # Pool2
# 展平:把 (batch, 64, 7, 7) → (batch, 64*7*7)
x = x.view(x.size(0), -1)
# 分类
x = torch.relu(self.fc1(x)) # FC1 + ReLU
x = self.fc2(x) # FC2(输出 logits)
return x测试一下:
model = MNIST_CNN()
test_input = torch.randn(2, 1, 28, 28) # 2张图片
output = model(test_input)
print(f"输入: {test_input.shape}") # → (2, 1, 28, 28)
print(f"输出: {output.shape}") # → (2, 10)
# 统计参数量
total = sum(p.numel() for p in model.parameters())
print(f"总参数: {total:,}")应该输出 (2, 10) —— 2 张图各得到 10 个得分值。网络可以正常工作。
💡 CNN 的参数量远少于纯全连接网络
以 MNIST 任务为例,全连接网络第一层需要 784×128 ≈ 10 万参数(784 对应 28×28 图片的全部像素),每个像素都有独立权重、无法复用。而 CNN 采用卷积核权重共享机制,同一个卷积核遍历整张图片、全程复用同一组参数,无需为每个图像位置单独设置权重,因此参数量大幅降低。
卷积层(nn.Conv2d):用小窗口滑动扫描图片,提取局部特征
卷积核(filter):每个核学一种特征模式(边、角、纹理等)
池化层(nn.MaxPool2d):缩小特征图,减少计算量,加平移不变性
CNN = 卷积 + 池化(特征提取)→ 全连接(分类)
padding=1 + kernel_size=3 能保持图片尺寸不变
点击查看完整的 搭完整的 MNIST 识别网络 示例代码。