简介

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
2
transforms.ToTensor()
transforms.Normalize((0.1307,), (0.3081,))

对于 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
2
3
1/9 × [1  1  1
1 1 1
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
2
3
3×3:每个探测器一次观察多大的局部区域
16:这一层有多少个输出特征通道
28×28:输入图片的空间大小

一个核在很多位置滑动,产生的是一张图上的很多位置,不是很多个通道。

在本例的步长和填充下,输入 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
2
3
4
输入通道 1 的局部 3×3  → 用对应权重计算 ──┐
输入通道 2 的局部 3×3 → 用对应权重计算 ──┤
…… ├→ 相加并加偏置 → 一个输出值
输入通道 16 的局部 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
输入:-2、0、3
输出: 0、0、3

它为网络引入非线性,使多个层的组合能够表达更复杂的关系。若只有连续的线性或仿射运算,中间又没有其他非线性操作,堆叠后仍可合并成一次线性或仿射变换。

最大池化保留什么

本例使用 nn.MaxPool2d(2),默认每次移动 2 格。每个 2×2 区域保留最大值:

1
2
[1  3]
[2 8] → 8

它相当于概括“这个局部区域里最强的响应有多大”。本例的空间尺寸依次变为:

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
原始分数:2.0、1.0、0.0
转换之后:约 66.5%、24.5%、9.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
2
3
4
python3 -m venv .venv
source .venv/bin/activate
python -m pip install torch torchvision
python cnn.py

将下面的完整代码保存为 cnn.py。如果沿用示例项目的目录结构,可以在项目根目录执行 python DL/cnn.py。

第一次运行需要联网下载 MNIST,数据保存在运行命令时所在目录的 data 文件夹。train=True 和 train=False 分别读取训练数据和测试数据,具体接口见 Torchvision MNIST 文档。

完整代码

代码按执行顺序展开,默认使用 CPU。先关注训练和测试,不加入命令行参数、模型保存或自定义图片识别功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
"""CNN 入门:用两层卷积识别 MNIST 手写数字(0~9)。

安装依赖:python3 -m pip install torch torchvision
运行脚本:python3 DL/cnn.py
首次运行会下载数据;本示例默认使用 CPU,方便先理解训练流程。
"""

# PyTorch 核心库:提供张量计算、自动求导等功能。
import torch
# 神经网络模块:提供卷积层、激活函数、池化层和全连接层。
import torch.nn as nn
# 优化器模块:根据梯度更新模型参数。
import torch.optim as optim
# DataLoader 将数据集分成多个批次,供训练和测试循环使用。
from torch.utils.data import DataLoader
# torchvision 提供 MNIST 数据集和图片预处理工具。
from torchvision import datasets, transforms


# 1. 准备数据
# 对每张图片先转张量,再按 MNIST 训练集的常用均值和标准差归一化。
transform = transforms.Compose([
# 将 28×28 灰度图片转为 [1, 28, 28] 的张量,像素值从 0~255 变为 0~1。
transforms.ToTensor(),
# 计算 (像素值 - 均值) / 标准差;归一化后的值不再限制在 0~1。
transforms.Normalize((0.1307,), (0.3081,)),
])

# root 是相对于运行命令时所在目录的数据路径。
# train=True 加载训练集,train=False 加载独立的测试集。
train_dataset = datasets.MNIST(
root='./data', train=True, download=True, transform=transform
)
test_dataset = datasets.MNIST(
root='./data', train=False, download=True, transform=transform
)

# 每批读取 64 张图片;训练时打乱顺序,减少固定样本顺序带来的影响
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False)


# 2. 定义网络
class SimpleCNN(nn.Module):
def __init__(self):
# 初始化父类,使 PyTorch 可以管理本模型的各层和参数。
super().__init__()

# 第一组:1 个输入通道 → 16 个特征通道。
# 3×3 卷积核、默认步长 1、边缘填充 1,保持图片宽高不变。
self.conv1 = nn.Conv2d(1, 16, kernel_size=3, padding=1)
# ReLU 将负数变为 0,为网络引入非线性。
self.relu1 = nn.ReLU()
# 每个 2×2 区域取最大值,宽高从 28×28 缩小到 14×14。
self.pool1 = nn.MaxPool2d(2)

# 第二组:16 个输入通道 → 32 个特征通道。
self.conv2 = nn.Conv2d(16, 32, kernel_size=3, padding=1)
self.relu2 = nn.ReLU()
# 第二次池化后,宽高从 14×14 缩小到 7×7。
self.pool2 = nn.MaxPool2d(2)

# 每张图片有 32×7×7=1568 个特征,输出对应数字 0~9 的 10 个分数。
self.fc = nn.Linear(32 * 7 * 7, 10)

def forward(self, x):
# N 是本批次图片数;张量维度顺序为 [批大小, 通道, 高, 宽]。
x = self.conv1(x) # [N, 1, 28, 28] → [N, 16, 28, 28]
x = self.relu1(x)
x = self.pool1(x) # → [N, 16, 14, 14]

x = self.conv2(x) # → [N, 32, 14, 14]
x = self.relu2(x)
x = self.pool2(x) # → [N, 32, 7, 7]

# 保留第 0 维的批大小,将后面的通道、高、宽合并为一维。
x = torch.flatten(x, start_dim=1) # → [N, 1568]
x = self.fc(x) # → [N, 10]
# 返回原始分数(logits);交叉熵损失前不需要再做 softmax。
return x


# 3. 创建模型、损失函数和优化器
model = SimpleCNN()
# 标签是 0~9 的整数,CrossEntropyLoss 衡量预测分数与真实类别的差异。
criterion = nn.CrossEntropyLoss()
# Adam 根据梯度调整模型参数,lr 表示学习率。
optimizer = optim.Adam(model.parameters(), lr=0.001)


# 4. 训练模型
# 一个 epoch(轮次)表示把整个训练集遍历一遍。
epochs = 3
for epoch in range(epochs):
model.train() # 切换到训练模式;它与开启梯度计算是不同的概念。
total_loss = 0.0

for data, target in train_loader:
# data:[本批图片数, 1, 28, 28];target:[本批图片数]。
optimizer.zero_grad() # 第一步:清除上一批次留下的梯度。
output = model(data) # 第二步:前向传播,计算每张图片的 10 个分数。
loss = criterion(output, target) # 第三步:计算本批次的平均损失。
loss.backward() # 第四步:反向传播,计算参数的梯度。
optimizer.step() # 第五步:根据梯度更新参数。

# 乘以本批样本数,最后按全部样本求平均,兼顾不足 64 张的最后一批。
total_loss += loss.item() * data.size(0)

average_loss = total_loss / len(train_dataset)
print(f'第 {epoch + 1}/{epochs} 轮,训练平均损失:{average_loss:.4f}')


# 5. 训练结束后,用独立测试集评估一次
model.eval() # 切换到评估模式。
correct = 0
# 测试只做前向传播,不计算梯度,也不更新模型参数。
with torch.no_grad():
for data, target in test_loader:
output = model(data) # [本批图片数, 10]
# 在第 1 维的 10 个分数中找到最大值的位置,即预测的数字。
pred = output.argmax(dim=1) # [本批图片数]
correct += (pred == target).sum().item()

accuracy = correct / len(test_dataset)
print(f'测试准确率:{accuracy:.2%}({correct}/{len(test_dataset)})')

理解训练循环的五步

代码 作用
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 的输出

这些演示中的网络结构不一定与本例一致。查看已经训练好的网络如何处理图片,能帮助理解前向传播,但不等于看到卷积核在现场训练更新。

参考资料