简介
CNN(Convolutional Neural Network,卷积神经网络)是一类适合处理图像等网格数据的神经网络。它通过卷积逐步提取和组合局部信息,再利用这些信息完成分类等任务。
以手写数字识别为例:输入一张只包含一个数字的灰度图片,网络输出它属于 0~9 各类别的分数,取最高分对应的类别作为预测。
刚开始学习时,容易把 CNN 里的卷积核和图像处理中的锐化、模糊卷积核混在一起,也容易把特征通道数理解成卷积窗口滑动的次数。本文以一个两层卷积的简单网络为例,先弄清这些概念,再看完整训练代码,尽量通过直观过程理解它的工作方式。
网络整体结构
本例的处理流程如下:
flowchart TD
A["输入:28×28 灰度图片"] --> B["卷积 + ReLU:16 张特征图"]
B --> C["最大池化:16 张 14×14 特征图"]
C --> D["卷积 + ReLU:32 张特征图"]
D --> E["最大池化:32 张 7×7 特征图"]
E --> F["展平:1568 个特征值"]
F --> G["全连接:10 个类别分数"]
G --> H["取最高分:预测数字"]
各部分承担不同的工作:
| 部分 | 主要作用 | 本例中是否有可学习参数 |
|---|---|---|
| 输入 | 接收经过预处理的图片张量 | 否 |
| 卷积 | 对局部区域计算响应,提取和组合特征 | 是 |
| ReLU | 将负值变成 0,引入非线性 | 否 |
| 最大池化 | 概括局部最大响应,缩小特征图 | 否 |
| 展平 | 将特征重新排列为向量,保留批次维度 | 否 |
| 全连接 | 综合输入特征,生成新的表示或分类分数 | 是 |
| 输出 | 用最终分数表示各类别的预测结果 | 本例由最后的全连接层承担 |
“输出层”描述的是这层的用途,不要求它是一个额外的组件。在本例中,最后一个全连接层就是输出层。
输入图片与通道
图片如何变成网络输入
MNIST 图片是 28×28 的灰度图。可以把一张图看成一张数字表,每个位置的数值代表对应像素的亮度。
PyTorch 中,一批图片通常按下面的顺序组织:
1 | [批大小, 通道数, 高, 宽] |
本例每批最多读取 64 张图片,因此通常是:
1 | [64, 1, 28, 28] |
其中,64 是本批图片的数量,1 表示灰度图片只有一个通道,28×28 是每张图片的空间尺寸。这几个数字表达的是不同含义。最后一批可能不足 64 张。
彩色 RGB 图片通常有红、绿、蓝三个输入通道;经过卷积后,通道表示网络计算出的特征,不再具有固定的颜色含义。
预处理在做什么
本例使用两步预处理:
1 | transforms.ToTensor() |
对于 MNIST 的普通灰度图片,第一步把像素值从 0~255 转为 0~1 的浮点数,并加入通道维度;第二步按指定均值和标准差做标准化,帮助调整输入的数值尺度。
0.1307 和 0.3081 是 MNIST 训练集常用的统计值,不适用于所有图片数据。标准化后的数值也不再限制在 0~1。
卷积核与特征图
卷积核到底是什么
先看第一层:
1 | nn.Conv2d(1, 16, kernel_size=3, padding=1) |
可以把一个卷积核想成一个小探测器:它覆盖图片中的局部区域,根据覆盖的像素和自己的权重算出一个响应值,然后移动到下一个位置,重复同样的计算。
对于单通道输入,一个 3×3 卷积核包含 9 个权重。它扫过整张图片后,各位置的响应组成一张特征图。同一个核在不同位置复用同一套权重,这叫做权重共享。
卷积核是可学习的参数;特征图是卷积核处理某张输入后产生的结果。 同一个核处理不同图片,会得到不同的特征图。
为什么选择 3×3
3×3 规定的是每次观察的局部范围,没有规定卷积核的用途。
选择它的原因是:既能结合相邻像素的信息,权重数量又相对少;把多层卷积叠起来,还能逐步组合更大范围的信息。对于相同输入、输出通道数,3×3 的权重数量少于 5×5。
也可以使用 5×5 或其他大小。3×3 是本例的设计选择,不是手写数字识别的唯一正确答案。
padding=1 表示在边缘补一圈像素。本例默认步长为 1,使用 3×3 核和这一填充时,卷积前后的宽高保持不变。若改为 5×5 并希望保持宽高,通常也要相应将填充改成 2。
它是锐化核,还是模糊核
传统图像处理中,可以人为指定一个均值模糊核:
1 | 1/9 × [1 1 1 |
它对邻近像素求平均,因此产生模糊效果。锐化核同样可以预先指定一组固定数值。
CNN 中通常不预先规定这些权重。我们确定核的尺寸、输入和输出通道数,框架初始化数值,再通过训练调整它们。
因此,不能看到 kernel_size=3 就认为使用了某种固定的锐化核。大小是设计的,数值是学习的。
模型怎样学会卷积核里的数值
把权重看成可调的旋钮
对于第一层的一个 3×3 核,可以想象有 9 个可调旋钮。旋钮决定每个位置的像素对响应产生怎样的影响。
初始化时,这些旋钮还没有获得适合手写数字分类的设置。网络先用当前参数做预测,随后依据预测的损失调整全部可学习参数,包括卷积核、偏置和全连接层权重。
从一次识别错误开始
假设输入图片真实是数字 7,模型却给数字 1 打了最高分。
训练先用真实标签 7 衡量预测分数是否合适。这个衡量结果叫损失。它不仅关心最终猜对还是猜错,也反映模型给真实类别分配的分数是否足够合适。
接下来,反向传播沿着刚才的计算过程往回追踪,计算每个参数对当前损失的影响。直观地理解,它在回答:
如果这个权重稍微增大,损失会朝哪个方向变化?影响有多大?
优化器依据这些信息对参数作小幅调整。反向传播负责计算梯度,优化器负责更新参数,两者分工不同。它们也不需要把每个权重真的修改一次、重新运行来逐个试错。PyTorch 自动求导介绍
flowchart TD
A["一批图片和真实标签"] --> B["当前参数计算预测分数"]
B --> C["损失函数衡量预测偏差"]
C --> D["反向传播:计算参数的梯度"]
D --> E["优化器:小幅调整参数"]
E --> F["读取下一批图片"]
F --> B
为什么可能学出笔画特征
如果对某种局部图案产生响应能帮助分类,那么相关参数就可能在多批训练中被调整成更有用的状态。例如,对某个方向的边缘产生响应,可能有助于后面的层判断笔画组合。
但这不意味着模型被明确告知“这个核负责找横线”。它得到的监督是数字标签,局部特征是在减少分类损失的过程中形成的。各层会共同调整,并不保证每个核都有明确、独立、可命名的功能。
每次更新也不保证在所有图片上都更好。训练使用一批批样本估计调整方向,学习率控制更新尺度;不合适的设置可能让训练不稳定。
16 个通道如何变成 32 个通道
16 不是卷积窗口滑出来的数量
第一层的 16 是人为设置的输出通道数,也就是安排 16 个不同的探测器,分别产生一张特征图。
1 | 3×3:每个探测器一次观察多大的局部区域 |
一个核在很多位置滑动,产生的是一张图上的很多位置,不是很多个通道。
在本例的步长和填充下,输入 28×28 图片会得到 16 张 28×28 特征图;输入 32×32 图片,会得到 16 张 32×32 特征图。通道数不会因此自动改变。
第二层的一个核会看全部 16 个输入通道
第二层定义为:
1 | nn.Conv2d(16, 32, kernel_size=3, padding=1) |
这里默认没有分组卷积。每个输出通道都综合全部 16 个输入通道:在每张输入特征图的对应位置取一个 3×3 区域,分别使用相应权重计算,再把这些结果相加,并加上偏置,生成一个输出位置的值。
1 | 输入通道 1 的局部 3×3 → 用对应权重计算 ──┐ |
所以,第二层的一个完整滤波器包含 16 片 3×3 权重,而不是只有 9 个权重。32 个这样的滤波器,产生 32 张输出特征图。Conv2d 参数和权重形状说明
| 卷积层 | 权重张量形状 | 每个输出通道使用的权重数,不含偏置 |
|---|---|---|
| 第一层 | [16, 1, 3, 3] |
1×3×3 = 9 |
| 第二层 | [32, 16, 3, 3] |
16×3×3 = 144 |
这不是把 16 张图各复制成两张,而是学习 32 种组合输入特征的方式。
16 和 32 都是网络设计选择。通道更多意味着可以学习更多种特征表示,但也增加计算和存储开销,不保证识别效果更好。
ReLU 与池化
ReLU 为什么放在卷积后面
ReLU 把负数变成 0,正数保持不变。例如:
1 | 输入:-2、0、3 |
它为网络引入非线性,使多个层的组合能够表达更复杂的关系。若只有连续的线性或仿射运算,中间又没有其他非线性操作,堆叠后仍可合并成一次线性或仿射变换。
最大池化保留什么
本例使用 nn.MaxPool2d(2),默认每次移动 2 格。每个 2×2 区域保留最大值:
1 | [1 3] |
它相当于概括“这个局部区域里最强的响应有多大”。本例的空间尺寸依次变为:
1 | 28×28 → 14×14 → 7×7 |
池化缩小宽高,不改变通道数;普通最大池化也没有需要训练的权重。它能让部分局部小位移对结果的影响减弱,但并不保证完全不受位置变化影响,而且会丢失细节。
展平、全连接与输出
为什么要展平
第二次池化后,每张图片对应 32 张 7×7 特征图,也就是 1568 个数。
展平把它们重新排列成一个向量:
1 | [N, 32, 7, 7] → [N, 1568] |
其中 N 是批大小。展平只改变组织方式,没有学习参数,也没有减少数值数量。进入全连接层后,每个输入位置的空间关系不再像卷积那样被局部连接结构显式利用。
全连接层怎样综合特征
本例用一个全连接层把 1568 个数映射成 10 个类别分数。每个输出都接收全部输入特征,但使用自己的一套权重。因此,不同类别可以根据不同的特征组合获得分数。
这 10 个分数不需要是正数,也不需要总和等于 1,通常称为 logits。
为什么可视化网站有两个全连接隐藏层
Adam Harley 的 CNN 演示采用了另一种结构:卷积和池化之后,有 120、100、10 个节点的三层全连接,界面将前两层标为全连接层,最后一层单独标为输出层。作者的结构说明
1 | 展平特征 → 120 个节点 → 100 个节点 → 10 个类别输出 |
两排长度不同,是因为神经元数量不同。“全连接”要求后面每个节点连接前一层全部节点,不要求两层节点数一样,也不是从 120 个数里挑选 100 个数。
隐藏层配合非线性激活,可以进一步组合特征。我们的入门网络省去了隐藏全连接层,直接分类;这两种结构都可以使用,层数多不代表效果一定更好。
输入尺寸改变后,哪里需要调整
卷积层可以扫描不同大小的图片,16、32 个输出通道仍然适用。但本例的全连接层写死了输入长度 32 * 7 * 7。
如果输入改成 32×32,两次池化后就变成 32 张 8×8 特征图,展平后长度不再是 1568,直接交给当前全连接层会报尺寸不匹配。
因此,本例要求把输入统一为 28×28。后续也可以通过调整全连接层或引入自适应池化处理这种尺寸变化。
为什么代码没有显式使用 softmax
softmax 把各类别原始分数转成总和为 1 的概率形式。例如,假设只有三个类别:
1 | 原始分数:2.0、1.0、0.0 |
训练使用的 nn.CrossEntropyLoss() 接收原始分数。对于本例的整数类别标签,它内部等价于先做 LogSoftmax 再计算负对数似然损失,所以不要在传入之前手动做 softmax。CrossEntropyLoss 接口说明
预测时,如果只需要类别,直接取最高分的位置即可:
1 | pred = output.argmax(dim=1) |
softmax 不改变类别分数的排序,所以省略它不影响最高分类别。如果需要显示每个数字的概率,可以额外计算:
1 | probabilities = torch.softmax(output, dim=1) |
这里 dim=1 是对每张图片的 10 个类别分数进行转换。得到的概率并不保证等于现实中的正确率或可靠程度。
PyTorch 手写数字识别实践
安装与运行
建议在虚拟环境中安装依赖:
1 | python3 -m venv .venv |
将下面的完整代码保存为 cnn.py。如果沿用示例项目的目录结构,可以在项目根目录执行 python DL/cnn.py。
第一次运行需要联网下载 MNIST,数据保存在运行命令时所在目录的 data 文件夹。train=True 和 train=False 分别读取训练数据和测试数据,具体接口见 Torchvision MNIST 文档。
完整代码
代码按执行顺序展开,默认使用 CPU。先关注训练和测试,不加入命令行参数、模型保存或自定义图片识别功能。
1 | """CNN 入门:用两层卷积识别 MNIST 手写数字(0~9)。 |
理解训练循环的五步
| 代码 | 作用 |
|---|---|
optimizer.zero_grad() |
清除上一批次留下的梯度,因为 PyTorch 默认累加梯度 |
output = model(data) |
前向传播,计算本批图片的分类分数 |
loss = criterion(output, target) |
根据真实标签计算损失 |
loss.backward() |
反向传播,计算各参数对损失的梯度 |
optimizer.step() |
优化器根据梯度更新参数 |
一个 epoch 表示遍历一遍训练集;一个 batch 表示一次送入网络的一批样本。训练 3 个 epoch 不等于只更新 3 次参数,每个 batch 通常都会更新一次。
shuffle=True 会在遍历训练集时打乱样本顺序,减少固定顺序带来的影响,但不能保证避免过拟合。
正确理解训练输出
每轮输出的是训练平均损失。代码先把每批平均损失乘以本批样本数,再除以全部训练样本数,这样最后一个较小批次也会得到正确的样本权重。
全部训练结束后,在测试集上统计预测正确的数量,除以测试样本总数,得到测试准确率。本文不预设准确率数值,实际结果取决于初始化、训练轮数和运行环境等因素。
model.eval() 和 torch.no_grad() 作用不同:前者切换某些层的训练/评估行为,后者关闭梯度记录。本例没有 Dropout 和 BatchNorm,因此 eval() 不会改变这些卷积、ReLU、池化和全连接层的计算规则,但保留规范的评估写法便于后续扩展。
学习时容易混淆的地方
| 容易产生的理解 | 实际含义 |
|---|---|
| 3×3 规定了核是锐化核或模糊核 | 它只规定空间大小,具体权重由训练学习 |
| 滑动 16 次得到 16 个通道 | 滑动位置决定特征图空间位置,16 是设置的输出通道数 |
| 第二层每个核仍然只有 9 个权重 | 本例每个输出通道使用 16×3×3 个权重 |
| 池化让通道从 16 变成 32 | 通道增加发生在卷积层,池化只缩小空间尺寸 |
| 展平会压缩掉一些数值 | 展平保留数值数量,只改变形状 |
| 120 到 100 的全连接是在删除 20 个数 | 它学习从全部 120 个输入计算出 100 个新输出 |
| 没有显式 softmax 就无法训练分类 | 交叉熵接收原始分数,预测类别可以直接 argmax |
| 训练损失下降就说明真实图片识别更可靠 | 还要检查独立数据上的表现和输入分布差异 |
MNIST 测试集上的准确率也不能直接代表拍照、倾斜或背景复杂的自写数字上的表现。如果需要反复选择轮数、通道数等设置,应从训练集中划分验证集,将测试集留给最终评估。
交互式学习工具
| 工具 | 适合观察什么 | 建议操作 |
|---|---|---|
| CNN Explainer | 各层特征图和卷积、激活、池化操作 | 点击特征图,查看计算细节,留意多通道如何组合 |
| 手写数字 CNN 3D 演示 | 输入数字如何影响各层响应和最终分类 | 先画 1,再改成 7,对比响应变化 |
| Convolution Visualizer | 卷积窗口的空间对应关系、步长和填充 | 保持核大小为 3,比较 padding 为 0 和 1 的输出 |
这些演示中的网络结构不一定与本例一致。查看已经训练好的网络如何处理图片,能帮助理解前向传播,但不等于看到卷积核在现场训练更新。