Skip to content

第 13 章 卷积神经网络

卷积神经网络(Convolutional Neural Network, CNN)主要用于图像和网格结构数据。和普通全连接网络相比,CNN 更重视数据的空间结构:相邻像素之间通常关系更强,同一种局部模式也可能出现在图像的不同位置。

本章先讨论 CNN 的结构基础,再讨论典型视觉任务、目标检测、图像分割和 MNIST 训练例子:

text
为什么用 CNN -> 卷积层 -> 池化和激活 -> 层级特征 -> 经典网络 -> 分类/检测/分割 -> 实操代码

13.1 为什么图像适合用 CNN

图像有两个重要特点:

  • 局部性:相邻像素往往关系更强,例如边缘、角点、纹理都来自局部区域。
  • 平移重复性:同一种局部模式可能出现在图像不同位置,例如眼睛可以出现在人脸左侧或右侧。

如果把图像直接展平成一个长向量,再接全连接层,会有两个问题:

  • 参数量很大,图像稍大时模型很容易变得难以训练。
  • 空间结构被破坏,模型不容易利用“相邻像素相关”这一信息。

CNN 通过卷积核在图像上滑动,保留局部结构,并用同一组参数检测不同位置的相同模式。

13.2 卷积层

卷积层用一个小窗口在输入图像上滑动,对局部区域做加权求和。这个小窗口就是卷积核(kernel)。

一个卷积核可以学习一种局部模式,例如边缘、角点、纹理。多个卷积核就能学习多种模式,并产生多个输出通道。卷积层输出通常叫特征图(feature map)。

常见参数:

参数含义
kernel size卷积核大小
stride滑动步长
padding边缘填充
channel通道数

输出尺寸

卷积层输出尺寸由输入大小、卷积核大小、padding 和 stride 决定。单个空间维度上,输出大小为:

O=I+2PKS+1

其中:

符号含义
I输入尺寸
K卷积核尺寸
Ppadding 大小
Sstride 大小
O输出尺寸

例如输入是 32 x 32,卷积核是 3 x 3,padding 为 1,stride 为 1,输出仍然是 32 x 32

padding 的作用是控制边缘像素如何参与卷积,并影响输出特征图尺寸。

通道和多卷积核

彩色图像通常有 3 个输入通道:R、G、B。卷积核在空间维度上滑动,同时覆盖所有输入通道。

如果输入特征图尺寸是:

text
H x W x C_in

一个卷积核的尺寸通常是:

text
K_h x K_w x C_in

其中:

符号含义
HW输入特征图的高和宽
Cin输入通道数
KhKw卷积核的高和宽
Cout输出通道数,也就是卷积核数量

如果有 Cout 个卷积核,就会得到 Cout 个输出通道。

所以卷积层参数量是:

Kh×Kw×Cin×Cout

如果包含偏置,还要再加 Cout 个偏置参数。

参数共享

全连接层中,每个输出单元都连接所有输入像素。图像稍大时,参数量会迅速变大。

卷积层的参数共享指同一个卷积核在不同位置重复使用。这样有两个好处:

  • 参数量大幅减少。
  • 同一个局部模式可以在不同位置被检测出来。

例如一个边缘检测卷积核,不需要在图像左上角、右下角分别学习一套参数。只要这个局部模式出现,卷积核就有机会响应。

这也是 CNN 对图像平移具有一定鲁棒性的原因之一。但 CNN 并不是完全平移不变,stride、padding、池化和数据增强都会影响这种性质。

13.3 激活函数、池化层和全连接层

一个典型 CNN 会把卷积、激活、池化、全连接等模块组合起来。

text
输入图像 -> 卷积 -> 激活 -> 池化 -> 卷积 -> 激活 -> 池化 -> 展平 -> 全连接 -> 输出

激活函数

卷积本身是线性运算,如果只堆叠卷积层,表达能力仍然有限。激活函数用于引入非线性。

CNN 中最常见的是 ReLU:

ReLU(x)=max(0,x)

ReLU 计算简单,可以缓解一部分梯度消失问题,因此在 CNN 中使用很广。

池化层

池化用于降低特征图尺寸,减少计算量,并增强一定的平移鲁棒性。

常见池化:

池化含义
Max Pooling取局部区域最大值
Average Pooling取局部区域平均值

现代网络中,有些结构会减少显式池化,改用带步长的卷积或全局平均池化。

全连接层

卷积和池化得到的是空间特征图。分类任务中,通常会把特征图展平,接全连接层输出类别分数。

最后一层输出的一般是 logits。训练多分类任务时,常用交叉熵损失,框架里的 CrossEntropyLoss 通常会把 softmax 和负对数似然合在一起计算,因此不需要手动先加 softmax。

13.4 感受野和层级特征

感受野指输出特征图上一个位置对应输入图像中的区域大小。

浅层神经元感受野小,通常学习边缘、角点、颜色变化等底层特征;深层神经元感受野更大,能组合出更抽象的物体部件和类别语义。

层级常见特征
浅层边缘、角点、颜色变化
中层纹理、局部形状、简单部件
深层物体部件、类别语义

这种层级结构由训练过程中的反向传播逐渐形成。考试里常见问法是:CNN 靠近输入的层通常学习局部、底层特征,例如边缘和纹理。

13.5 经典 CNN 结构

经典 CNN 模型可以帮助理解网络结构是如何逐步发展起来的。

模型特点
LeNet早期手写数字识别网络,结构较浅
AlexNet推动深度学习在 ImageNet 上取得突破
VGG结构规整,使用多个小卷积核堆叠
ResNet引入残差连接,便于训练深层网络

ResNet 的核心思想是残差连接:

text
输出 = F(x) + x

它让网络更容易学习恒等映射,缓解深层网络退化问题。这里的退化指网络加深后训练误差反而变差,不等同于过拟合。

13.6 数据增强和迁移学习

数据增强

图像数据增强可以提升泛化能力。常见方法:

  • 随机裁剪。
  • 随机翻转。
  • 随机旋转。
  • 平移和缩放。
  • 颜色扰动。

数据增强要保证不改变样本语义。例如数字识别里,某些旋转可能会把 6 变得像 9,这种增强就要谨慎。

迁移学习

图像任务常用在 ImageNet 上预训练的模型作为特征提取器。

常见做法:

  1. 加载预训练 CNN。
  2. 去掉或替换最后的分类层。
  3. 冻结大部分卷积层。
  4. 在自己的数据上训练新的分类头。

如果数据较多,也可以微调整个网络。数据较少时,只训练顶部分类层通常更稳。

13.7 图像分类、目标检测和图像分割

视觉任务可以按输出形式区分:

任务目标输出
图像分类判断图里是什么整张图一个类别
目标检测判断图里有什么,并定位目标目标框、类别、置信度
图像分割判断每个像素属于什么类别每个像素的类别

图像分类

图像分类只回答“图里是什么”。例如 MNIST 手写数字分类中,输入是一张数字图片,输出是 09 中的一个类别。

分类任务只需要整张图的类别标签,训练数据形式比较简单:

text
输入:一张图片
输出:一个类别

目标检测和图像分割都比分类更细。检测需要定位目标位置,分割需要判断像素级类别。CNN 在这些任务中通常作为特征提取骨干网络,后面的检测头或分割头再根据特征图产生具体输出。

13.8 目标检测

目标检测还要回答“在哪里”。检测模型通常会预测:

输出含义
class score每个类别的置信度
box coordinate边界框位置
objectness当前位置是否有目标

边界框一般用四个数表示。例如:

text
(x_min, y_min, x_max, y_max)

也可以写成:

text
(x_center, y_center, width, height)

前一种形式适合直接表示框的左上角和右下角,后一种形式更常用于检测模型内部预测。

检测模型的输出通常包含两部分:

  • 分类:这个框里是什么类别。
  • 回归:这个框的位置应该怎么调整。

因此目标检测的损失函数也常由两部分组成:

L=Lcls+λLbox

其中 Lcls 是分类损失,Lbox 是边界框回归损失,λ 用来平衡两部分损失。

IoU

IoU(Intersection over Union)用来衡量预测框和真实框的重合程度。设预测框为 Bp,真实框为 Bg

IoU=area(BpBg)area(BpBg)

IoU 越大,说明预测框越接近真实框。

常见判断方式:

IoU含义
接近 0预测框和真实框几乎不重合
0.5 左右有一定重合,很多任务中可作为基本匹配阈值
接近 1预测框非常接近真实框

在目标检测评估中,常见指标有 Precision、Recall、AP(Average Precision)和 mAP(mean Average Precision)。mAP 可以理解为多个类别、多个阈值下检测效果的综合评价。

NMS

目标检测模型常会对同一个目标预测出多个相似框。非极大值抑制(Non-Maximum Suppression, NMS)用于去掉重复框。

NMS 的基本流程:

  1. 按置信度从高到低排序所有预测框。
  2. 取置信度最高的框作为保留框。
  3. 删除和它 IoU 过高的其他框。
  4. 对剩下的框重复上述过程。

NMS 的作用不是提高模型本身的特征表达能力,而是在后处理阶段减少重复检测结果。

两阶段检测和一阶段检测

目标检测方法可以粗略分为两类。

类型代表模型基本思想特点
两阶段检测R-CNN、Fast R-CNN、Faster R-CNN先找候选区域,再分类和回归精度较高,速度较慢
一阶段检测YOLO、SSD直接在特征图上预测类别和边界框速度快,适合实时检测

R-CNN 系列通常先生成候选区域,再对候选区域分类和回归边界框。Faster R-CNN 引入 RPN(Region Proposal Network)生成候选框,使候选区域生成也能由网络学习。

YOLO(You Only Look Once)把检测看成一次前向传播中的密集预测。它直接在不同位置、不同尺度上预测目标类别和边界框,因此速度更快。

目标检测中还要注意小目标问题。深层特征语义强,但分辨率低;浅层特征分辨率高,但语义弱。因此现代检测模型常使用多尺度特征,例如 FPN(Feature Pyramid Network),让模型同时利用不同尺度的信息。

13.9 图像分割和 U-Net

图像分割要对每个像素分类,比目标检测更细。

图像分割常见任务包括:

类型含义例子
语义分割给每个像素分配类别,不区分同类不同个体道路、天空、汽车
实例分割不仅分类别,还区分同一类别的不同个体第 1 个人、第 2 个人
全景分割同时处理背景语义和前景实例自动驾驶场景理解

语义分割的输出尺寸通常和输入图像相同。若输入图像为:

text
H x W x C

语义分割输出可以看成:

text
H x W x K

其中 K 是类别数。每个像素位置都有一个长度为 K 的类别分数,最后取分数最高的类别作为该像素的预测类别。

全卷积网络

早期 CNN 分类模型最后常接全连接层,输出整张图的类别。图像分割不能直接这样做,因为分割需要保留空间位置。

全卷积网络(Fully Convolutional Network, FCN)把全连接层改成卷积层,使网络可以输出空间特征图。然后通过上采样把低分辨率特征图恢复到接近原图大小。

常见上采样方式包括:

  • 双线性插值。
  • 反卷积,也叫转置卷积。
  • 上采样后接普通卷积。

U-Net 结构

U-Net 常用于医学图像分割。它的结构可以理解为编码器-解码器:

text
编码器:逐步降低分辨率,提取语义特征
解码器:逐步恢复分辨率,生成像素级预测
跳跃连接:融合浅层空间细节和深层语义信息

U-Net 名字来自整体结构形状类似字母 U。左侧编码器不断下采样,右侧解码器不断上采样,中间通过跳跃连接把编码器的浅层特征传给解码器。

编码器负责扩大感受野、提取语义信息。经过多次卷积和池化后,特征图分辨率下降,但每个位置看到的原图区域更大。

解码器负责恢复空间分辨率。它通过上采样逐步把特征图放大,使输出重新回到像素级预测。

跳跃连接是 U-Net 的关键。图像分割要求输出和输入在空间上精细对齐。深层特征语义强,但分辨率低;浅层特征分辨率高,但语义弱。U-Net 把浅层细节和深层语义结合起来,能更好地恢复目标边界。

可以这样理解 U-Net 的信息流:

text
浅层特征:边缘、纹理、位置细节
深层特征:类别语义、整体结构
跳跃连接:把位置细节补回解码器

医学图像分割中常见的问题是样本数量少、目标边界细、病灶区域小。U-Net 对这类任务比较合适,因为它既能利用深层语义,又能保留浅层空间细节。

分割损失函数

语义分割可以看成对每个像素做分类,因此常用像素级交叉熵损失:

L=1HWi=1Hj=1Wk=1Kyijklogy^ijk

其中 HW 是图像高和宽,K 是类别数,yijk 表示像素 (i,j) 是否属于第 k 类,y^ijk 是模型预测概率。

如果前景目标很小,背景像素远多于前景像素,只用交叉熵可能会让模型偏向预测背景。这时常见做法包括类别加权、Dice Loss 或交叉熵与 Dice Loss 结合。

Dice 系数常用于衡量预测区域和真实区域的重合程度:

Dice=2|AB||A|+|B|

其中 A 是预测分割区域,B 是真实分割区域,|A||B| 表示区域中的像素数量。

Dice 越大,说明预测分割区域和真实区域越接近。

13.10 MNIST CNN 实操

下面是一份完整的 MNIST CNN 训练和测试代码:

python
from torchvision import datasets, transforms  # 从 torchvision 导入数据集工具和图像预处理工具
from torch.utils.data import DataLoader  # 导入 DataLoader,用来按 batch 加载数据
import torch.nn as nn  # 导入神经网络模块,常用别名是 nn
import torch  # 导入 PyTorch 主库

device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")  # 有 CUDA 显卡就用 GPU,否则用 CPU
transform = transforms.Compose([  # 把多个图像预处理步骤组合起来
    transforms.ToTensor(),  # 把图片转成 Tensor,并把像素值从 0~255 缩放到 0~1
    transforms.Normalize((0.1307,), (0.3081,)),  # 用 MNIST 的均值和标准差做归一化,让训练更稳定
])  # 预处理组合结束
model = nn.Sequential(  # 用 Sequential 按顺序搭建神经网络
    nn.Conv2d(1, 32, 3),  # 第一层卷积:输入 1 个灰度通道,输出 32 个特征通道,卷积核大小 3x3
    nn.BatchNorm2d(32),  # 对 32 个卷积输出通道做批归一化,让训练更稳定
    nn.ReLU(),  # 激活函数,把负数变成 0,增加模型的非线性表达能力
    nn.Conv2d(32, 64, 3),  # 第二层卷积:输入 32 个通道,输出 64 个通道,卷积核大小 3x3
    nn.BatchNorm2d(64),  # 对 64 个卷积输出通道做批归一化
    nn.ReLU(),  # 再次使用 ReLU 激活函数
    nn.MaxPool2d(2),  # 最大池化,把特征图宽高缩小一半,减少计算量
    nn.Flatten(),  # 把多维特征图展平成一维向量,方便接全连接层
    nn.Dropout(0.3),  # 训练时随机丢弃 30% 的神经元输出,降低过拟合
    nn.Linear(9216, 128),  # 全连接层:把 9216 个输入特征映射到 128 个特征
    nn.ReLU(),  # 全连接层后继续加 ReLU 激活
    nn.Dropout(0.3),  # 再做一次 Dropout,继续降低过拟合风险
    nn.Linear(128, 10),  # 输出层:把 128 个特征映射到 10 类数字
).to(device)  # 把模型参数移动到 CPU 或 GPU 上
traindata = datasets.MNIST(root='./data', train=True, download=True, transform=transform)  # 下载/读取 MNIST 训练集
traindataloader = DataLoader(traindata, batch_size=64, shuffle=True)  # 把训练集包装成 DataLoader,每批 64 张,训练时打乱顺序
opt = torch.optim.Adam(model.parameters(), lr=0.001)  # 创建 Adam 优化器,用来更新模型参数
criterion = nn.CrossEntropyLoss()  # 创建交叉熵损失函数,适合多分类任务
for epoch in range(0, 10):  # 训练 10 轮,epoch 表示完整看一遍训练集
    model.train()  # 切换到训练模式,启用 Dropout 和 BatchNorm 的训练行为
    for x, y in traindataloader:  # 每次从训练集中取出一批图片 x 和标签 y
        x = x.to(device)  # 把图片移动到当前设备
        y = y.to(device)  # 把标签移动到当前设备
        opt.zero_grad()  # 清空上一轮反向传播留下的梯度
        output = model(x)  # 前向传播:把图片输入模型,得到 10 类分数
        loss = criterion(output, y)  # 计算 loss,衡量预测结果和真实标签的差距
        loss.backward()  # 反向传播,根据 loss 计算每个参数的梯度
        opt.step()  # 优化器根据梯度更新模型参数
    print(loss.item())  # 打印当前 epoch 最后一个 batch 的 loss
torch.save(model.state_dict(), './model.pth')  # 保存模型参数到 model.pth

valdata = datasets.MNIST(root='./data', train=False, download=True, transform=transform)  # 下载/读取 MNIST 测试集
valdataloader = DataLoader(valdata, batch_size=1000, shuffle=False)  # 测试集 DataLoader,每批 1000 张,测试时不需要打乱
model.eval()  # 切换到评估模式,关闭 Dropout,并让 BatchNorm 使用训练时累计的统计值
correct = 0  # 记录预测正确的样本数量
total = 0  # 记录测试过的样本总数量
with torch.no_grad():  # 测试时不计算梯度,速度更快,也更省显存
    for x, y in valdataloader:  # 每次从测试集中取出一批图片 x 和标签 y
        x = x.to(device)  # 把测试图片移动到当前设备
        y = y.to(device)  # 把测试标签移动到当前设备
        pred = model(x).argmax(dim=1)  # 模型输出 10 类分数,取分数最大的类别作为预测结果
        correct += (pred == y).sum().item()  # 统计这一批里预测正确的数量,并累加
        total += y.size(0)  # 累加这一批的样本数量
print(correct / total)  # 打印测试集准确率

Powered by VitePress