跳转至

08-卷积神经网络

学习时间: 约8-10小时 难度级别: ⭐⭐⭐ 中级 前置知识: 神经网络基础、反向传播算法、正则化技术、优化器 学习目标: 深入理解卷积神经网络的原理与核心组件,掌握经典CNN架构的设计思想,能够用PyTorch实现图像分类任务


🎯 学习目标

  1. 理解卷积操作的数学原理(互相关运算、卷积核、步长、填充)
  2. 掌握卷积层输入输出尺寸的计算公式
  3. 理解池化层的作用与不同类型
  4. 熟悉CNN的典型结构与数据流
  5. 深入了解经典CNN架构及其创新点(LeNet → EfficientNet 演进)
  6. 掌握1×1卷积、转置卷积、空洞卷积、深度可分离卷积等高级技术
  7. 理解数据增强和迁移学习的实践方法
  8. 能够用PyTorch完成CIFAR-10图像分类任务

目录


1. 卷积操作原理

卷积操作

1.1 从全连接到卷积

全连接神经网络处理图像存在两个核心问题:

  1. 参数爆炸:一张 \(224 \times 224 \times 3\) 的图像输入全连接层,仅一个神经元就需要 \(224 \times 224 \times 3 = 150{,}528\) 个权重
  2. 忽略空间结构:将图像展平为一维向量,丢失了像素间的空间关系

卷积神经网络通过两个关键特性解决这些问题:局部连接(每个神经元只看一小块区域)和权重共享(同一个卷积核在整张图像上滑动)。

1.2 互相关运算(Cross-Correlation)

严格意义上,深度学习中使用的"卷积"实际是互相关运算(不翻转卷积核),但习惯上仍称为卷积。

对于二维输入 \(\mathbf{X} \in \mathbb{R}^{H \times W}\) 和卷积核 \(\mathbf{K} \in \mathbb{R}^{k_H \times k_W}\),输出 \(\mathbf{Y}\) 的第 \((i,j)\) 个元素为:

\[Y[i, j] = \sum_{m=0}^{k_H-1} \sum_{n=0}^{k_W-1} X[i+m, j+n] \cdot K[m, n] + b\]

其中 \(b\) 是偏置项。

直觉理解:卷积核(滤波器)像一个"窗口"在输入图像上滑动,每到一个位置就与覆盖区域做逐元素乘法再求和,得到一个标量输出值。

1.3 卷积核(Filter / Kernel)

卷积核是一组可学习的权重矩阵,不同的卷积核可以检测不同的特征:

  • 边缘检测核:检测水平/垂直/对角边缘
  • 模糊核:平滑图像
  • 锐化核:增强图像细节
Python
import torch
import torch.nn.functional as F

# 示例:手动边缘检测
# 水平边缘检测核
kernel_horizontal = torch.tensor([
    [-1, -1, -1],
    [ 0,  0,  0],
    [ 1,  1,  1]
], dtype=torch.float32).reshape(1, 1, 3, 3)  # reshape重塑张量形状

# 垂直边缘检测核
kernel_vertical = torch.tensor([
    [-1, 0, 1],
    [-1, 0, 1],
    [-1, 0, 1]
], dtype=torch.float32).reshape(1, 1, 3, 3)

# 对一张灰度图应用卷积
# input shape: (batch_size, channels, height, width)
image = torch.randn(1, 1, 28, 28)
edges_h = F.conv2d(image, kernel_horizontal, padding=1)
edges_v = F.conv2d(image, kernel_vertical, padding=1)

1.4 步长(Stride)

步长控制卷积核每次移动的像素数:

  • stride=1:卷积核每次移动1个像素(默认)
  • stride=2:卷积核每次移动2个像素,输出尺寸减半(起到下采样作用)

步长大于1时可以替代池化操作,减少计算量。

1.5 填充(Padding)

填充是在输入边缘补零(或其他值),用于控制输出尺寸:

  • Valid(无填充):不填充,输出尺寸小于输入
  • Same(等宽填充):填充使得输出尺寸等于输入尺寸(stride=1时,对于 \(k \times k\) 核,填充 \(p = \lfloor k/2 \rfloor\)
  • Full(全填充):填充 \(p = k - 1\),输出尺寸大于输入

1.6 多通道卷积

实际输入通常是多通道的(如RGB图像有3个通道)。设输入有 \(C_{in}\) 个通道,则一个卷积核的实际形状为 \(C_{in} \times k_H \times k_W\),对每个通道分别卷积后求和,输出一个单通道特征图。

若需要 \(C_{out}\) 个输出通道,则使用 \(C_{out}\) 个这样的卷积核,卷积层总参数量为:

\[\text{参数量} = C_{out} \times (C_{in} \times k_H \times k_W + 1)\]

其中 \(+1\) 是偏置项。


2. 卷积层数学表达

2.1 输出尺寸计算公式

给定输入尺寸 \(H_{in} \times W_{in}\),卷积核大小 \(k\),步长 \(s\),填充 \(p\),输出尺寸为:

\[H_{out} = \left\lfloor \frac{H_{in} + 2p - k}{s} \right\rfloor + 1\]
\[W_{out} = \left\lfloor \frac{W_{in} + 2p - k}{s} \right\rfloor + 1\]

计算示例

场景 \(H_{in}\) \(k\) \(s\) \(p\) \(H_{out}\)
基本卷积 32 3 1 0 30
Same填充 32 3 1 1 32
下采样 32 3 2 1 16
大核卷积 32 5 1 2 32
7×7首层 224 7 2 3 112

2.2 感受野(Receptive Field)

感受野

感受野是指输出特征图上一个像素在原始输入上"看到"的区域大小。

对于 \(L\) 层连续卷积(核大小 \(k_l\),步长 \(s_l\)),第 \(L\) 层的感受野为:

\[RF_L = 1 + \sum_{l=1}^{L} (k_l - 1) \prod_{i=1}^{l-1} s_i\]

例如,3层 \(3 \times 3\) 卷积(步长均为1)的感受野为 \(1 + 2 + 2 + 2 = 7\),这就是VGGNet用多个小卷积核替代大卷积核的数学依据。

2.3 计算量分析

单层卷积的浮点运算量(FLOPs):

\[\text{FLOPs} = 2 \times C_{in} \times k^2 \times C_{out} \times H_{out} \times W_{out}\]

其中因子2来自乘法和加法各一次。


3. 池化层

池化操作

池化层用于降低特征图的空间维度,减少参数量和计算量,同时提供一定的平移不变性。

3.1 最大池化(Max Pooling)

取池化窗口中的最大值,保留最显著的特征:

\[Y[i, j] = \max_{0 \le m < k, \, 0 \le n < k} X[i \cdot s + m, \, j \cdot s + n]\]

常用配置:\(2 \times 2\) 窗口,步长2,将特征图尺寸减半。

Python
import torch.nn as nn

# 最大池化层
max_pool = nn.MaxPool2d(kernel_size=2, stride=2)  # 输出尺寸减半

3.2 平均池化(Average Pooling)

取池化窗口中的平均值,保留所有特征的概要信息:

\[Y[i, j] = \frac{1}{k^2} \sum_{m=0}^{k-1} \sum_{n=0}^{k-1} X[i \cdot s + m, \, j \cdot s + n]\]

3.3 全局平均池化(Global Average Pooling, GAP)

对每个通道的整个特征图取平均值,输出一个标量。将 \(C \times H \times W\) 的特征图变为 \(C \times 1 \times 1\)

\[Y[c] = \frac{1}{H \times W} \sum_{i=0}^{H-1} \sum_{j=0}^{W-1} X[c, i, j]\]

优点:无可学习参数,可替代全连接层,减少过拟合风险。被GoogLeNet和ResNet等广泛采用。

Python
# 全局平均池化
gap = nn.AdaptiveAvgPool2d((1, 1))  # 输出固定为 1x1
# 使用示例
x = torch.randn(8, 512, 7, 7)  # (batch, channels, h, w)
out = gap(x)  # (8, 512, 1, 1)
out = out.view(out.size(0), -1)  # (8, 512) → 送入分类器

3.4 池化层的输出尺寸

池化层的输出尺寸计算公式与卷积层相同(无填充时):

\[H_{out} = \left\lfloor \frac{H_{in} - k}{s} \right\rfloor + 1\]

4. CNN典型结构

CNN架构

CNN架构

4.1 经典数据流

一个典型的CNN遵循以下结构范式:

Text Only
输入图像
  → [卷积层 → 激活函数(ReLU) → 池化层] × N  (特征提取)
  → 展平 (Flatten)
  → [全连接层 → 激活函数] × M  (分类决策)
  → 输出层 (Softmax/Sigmoid)

4.2 各层的作用

层类型 作用 特点
卷积层 提取局部特征 权重共享,局部连接
激活函数 引入非线性 ReLU最常用
池化层 下采样,减少参数 提供平移不变性
Batch Norm 稳定训练,加速收敛 现代CNN标配
全连接层 综合特征,输出分类 参数量大
Dropout 防止过拟合 训练时随机丢弃

4.3 特征层次

CNN自底向上学习越来越抽象的特征:

  • 浅层(第1-2层):边缘、纹理、颜色
  • 中层(第3-4层):局部形状、纹理组合
  • 深层(第5层及以上):物体部件、语义特征

5. 经典架构详解

CNN架构对比

5.1 LeNet-5(1998, Yann LeCun)

结构

Text Only
输入 (1×32×32)
→ Conv(6, 5×5, s=1) → Sigmoid → AvgPool(2×2, s=2)
→ Conv(16, 5×5, s=1) → Sigmoid → AvgPool(2×2, s=2)
→ Flatten
→ FC(120) → Sigmoid
→ FC(84) → Sigmoid
→ FC(10) → Output

历史意义: - 第一个成功的CNN,用于手写数字识别(邮政编码) - 证明了卷积+池化+全连接的有效性 - 奠定了CNN的基本范式 - 参数量仅约60K,在当时的计算条件下即可训练

Python
class LeNet5(nn.Module):  # 继承nn.Module定义神经网络层
    def __init__(self, num_classes=10):  # __init__构造方法,创建对象时自动调用
        super().__init__()  # super()调用父类方法
        self.features = nn.Sequential(
            nn.Conv2d(1, 6, kernel_size=5),
            nn.Sigmoid(),
            nn.AvgPool2d(kernel_size=2, stride=2),
            nn.Conv2d(6, 16, kernel_size=5),
            nn.Sigmoid(),
            nn.AvgPool2d(kernel_size=2, stride=2),
        )
        self.classifier = nn.Sequential(
            nn.Linear(16 * 5 * 5, 120),
            nn.Sigmoid(),
            nn.Linear(120, 84),
            nn.Sigmoid(),
            nn.Linear(84, num_classes),
        )

    def forward(self, x):
        x = self.features(x)
        x = x.view(x.size(0), -1)
        return self.classifier(x)

5.2 AlexNet(2012, Alex Krizhevsky)

AlexNet在2012年ImageNet竞赛中以巨大优势夺冠(top-5错误率从26%降到16.4%),开启了深度学习的黄金时代。

关键突破点: 1. ReLU激活函数:取代Sigmoid/Tanh,解决梯度消失,训练速度提升6倍 2. Dropout正则化:在全连接层使用Dropout(p=0.5),有效防止过拟合 3. 数据增强:随机裁剪、水平翻转、颜色抖动 4. GPU训练:首次使用两块GTX 580 GPU并行训练 5. 局部响应归一化(LRN):通道间竞争(后被BatchNorm替代)

结构:8层(5卷积+3全连接),约60M参数

5.3 VGGNet(2014, Karen Simonyan & Andrew Zisserman)

核心思想——深度的力量:用多个 \(3 \times 3\) 卷积堆叠替代大卷积核。

为什么用 \(3 \times 3\) - 两个 \(3 \times 3\) 卷积的感受野等价于一个 \(5 \times 5\) 卷积 - 三个 \(3 \times 3\) 卷积的感受野等价于一个 \(7 \times 7\) 卷积 - 参数量更少:\(3 \times (3^2 C^2) = 27C^2\) vs \(7^2 C^2 = 49C^2\) - 更多非线性:每层后接ReLU,增强网络表达能力

VGG-16结构

Text Only
输入 (3×224×224)
→ [Conv3-64] × 2 → MaxPool     → 112×112×64
→ [Conv3-128] × 2 → MaxPool    → 56×56×128
→ [Conv3-256] × 3 → MaxPool    → 28×28×256
→ [Conv3-512] × 3 → MaxPool    → 14×14×512
→ [Conv3-512] × 3 → MaxPool    → 7×7×512
→ FC-4096 → FC-4096 → FC-1000

参数量约138M,大部分在全连接层。

5.4 GoogLeNet / Inception(2014, Google)

Inception模块

核心创新——Inception模块:在同一层使用不同大小的卷积核并行提取多尺度特征。

Inception模块结构

Text Only
         输入
    ╱    │    │    ╲
  1×1  1×1  1×1  MaxPool3×3
   │   3×3  5×5   1×1
    ╲    │    │    ╱
      拼接 (Concatenate)

1×1卷积降维:在3×3和5×5卷积之前使用1×1卷积减少通道数,大幅降低计算量。

例如:输入 \(28 \times 28 \times 192\),直接用32个 \(5 \times 5\) 卷积需要 \(28 \times 28 \times 32 \times 192 \times 5 \times 5 \approx 120M\) 次运算;先用1×1卷积降到16通道,再用5×5卷积,计算量降至约 \(12M\),节省10倍。

GoogLeNet特点: - 22层深,但参数仅约5M(VGG的1/28) - 使用全局平均池化替代全连接层 - 辅助分类器帮助中间层训练(缓解梯度消失)

5.5 ResNet(2015, Kaiming He)

ResNet残差块

核心创新——残差连接:解决了深层网络的退化问题(更深的网络反而效果更差)。

残差块(Residual Block)

\[\mathbf{y} = \mathcal{F}(\mathbf{x}, \{W_i\}) + \mathbf{x}\]

其中 \(\mathcal{F}\) 是残差映射(两层或三层卷积),\(\mathbf{x}\) 是恒等映射(跳跃连接/shortcut)。

为什么有效——梯度高速公路

反向传播时梯度可以通过跳跃连接直接回传:

\[\frac{\partial \mathcal{L}}{\partial \mathbf{x}} = \frac{\partial \mathcal{L}}{\partial \mathbf{y}} \cdot \left(\frac{\partial \mathcal{F}}{\partial \mathbf{x}} + 1\right)\]

加号右边的 \(1\) 保证梯度至少为1,不会消失。

基本块 vs 瓶颈块

Python
class BasicBlock(nn.Module):
    """ResNet-18/34 使用的基本残差块"""
    expansion = 1

    def __init__(self, in_channels, out_channels, stride=1, downsample=None):
        super().__init__()
        self.conv1 = nn.Conv2d(in_channels, out_channels, 3, stride, 1, bias=False)
        self.bn1 = nn.BatchNorm2d(out_channels)
        self.conv2 = nn.Conv2d(out_channels, out_channels, 3, 1, 1, bias=False)
        self.bn2 = nn.BatchNorm2d(out_channels)
        self.downsample = downsample  # 当维度不匹配时用1×1卷积调整
        self.relu = nn.ReLU(inplace=True)

    def forward(self, x):
        identity = x
        out = self.relu(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        if self.downsample is not None:
            identity = self.downsample(x)
        out += identity  # 残差连接
        return self.relu(out)

class Bottleneck(nn.Module):
    """ResNet-50/101/152 使用的瓶颈残差块"""
    expansion = 4

    def __init__(self, in_channels, mid_channels, stride=1, downsample=None):
        super().__init__()
        out_channels = mid_channels * self.expansion
        self.conv1 = nn.Conv2d(in_channels, mid_channels, 1, bias=False)  # 1×1降维
        self.bn1 = nn.BatchNorm2d(mid_channels)
        self.conv2 = nn.Conv2d(mid_channels, mid_channels, 3, stride, 1, bias=False)  # 3×3卷积
        self.bn2 = nn.BatchNorm2d(mid_channels)
        self.conv3 = nn.Conv2d(mid_channels, out_channels, 1, bias=False)  # 1×1升维
        self.bn3 = nn.BatchNorm2d(out_channels)
        self.downsample = downsample
        self.relu = nn.ReLU(inplace=True)

    def forward(self, x):
        identity = x
        out = self.relu(self.bn1(self.conv1(x)))
        out = self.relu(self.bn2(self.conv2(out)))
        out = self.bn3(self.conv3(out))
        if self.downsample is not None:
            identity = self.downsample(x)
        out += identity
        return self.relu(out)

深度突破:ResNet成功训练了152层甚至1000层的网络,证明了残差连接可以有效解决深层网络的训练难题。

5.6 DenseNet(2017, Gao Huang)

DenseNet密集连接

核心创新——密集连接:每一层都与前面所有层连接,实现最大程度的特征复用。

\[\mathbf{x}_l = H_l([\mathbf{x}_0, \mathbf{x}_1, \ldots, \mathbf{x}_{l-1}])\]

其中 \([\cdot]\) 表示通道维度的拼接(concatenation)。

优点: - 特征复用:避免冗余特征学习 - 隐式深度监督:每层都能直接访问损失函数的梯度 - 参数效率:每层只需很少的卷积核(增长率 \(k=12\)\(k=32\)) - 正则化效果:密集连接本身起到正则化作用

与ResNet的区别:ResNet是加法(\(x + F(x)\)),DenseNet是拼接(\([x, F(x)]\))。

5.7 EfficientNet(2019, Mingxing Tan & Quoc Le)

核心创新——复合缩放策略:同时缩放网络的深度(\(d\))、宽度(\(w\))和分辨率(\(r\))。

\[d = \alpha^\phi, \quad w = \beta^\phi, \quad r = \gamma^\phi\]

满足约束:\(\alpha \cdot \beta^2 \cdot \gamma^2 \approx 2\)

通过神经架构搜索(NAS)找到基线EfficientNet-B0,再用复合缩放得到B1-B7系列。

EfficientNet-B7以66M参数达到了84.3% ImageNet top-1准确率,效率远超之前的模型。


6. 高级卷积技术

6.1 1×1卷积的作用

1x1卷积

\(1 \times 1\) 卷积看似简单,实际功能强大:

  1. 降维:减少通道数,降低计算量(如Inception中的使用)
  2. 升维:增加通道数,增强表达能力
  3. 跨通道交互:对每个空间位置的所有通道进行线性组合
  4. 增加非线性:配合激活函数,增加网络深度而不改变空间维度
Python
# 1×1卷积:将512通道降到64通道
reduce = nn.Conv2d(512, 64, kernel_size=1)
# 参数量仅:512 × 64 + 64 = 32,832

等价于对每个空间位置的通道向量做一次全连接变换,因此也称为逐点卷积(Pointwise Convolution)

6.2 转置卷积(Transposed Convolution)

转置卷积

转置卷积用于上采样(upsampling),将低分辨率特征图恢复到高分辨率,常用于语义分割和生成模型。

转置卷积的输出尺寸:

\[H_{out} = (H_{in} - 1) \times s - 2p + k + \text{output\_padding}\]
Python
# 转置卷积:将特征图放大2倍
upsample = nn.ConvTranspose2d(256, 128, kernel_size=4, stride=2, padding=1)
# 输入 (256, 8, 8) → 输出 (128, 16, 16)

注意:转置卷积容易产生棋盘格伪影(checkerboard artifact),实践中常用 nn.Upsample + nn.Conv2d 替代。

6.3 空洞卷积(Dilated / Atrous Convolution)

空洞卷积

空洞卷积在卷积核元素之间插入空洞(dilation),在不增加参数的情况下扩大感受野。

空洞率为 \(d\) 时,有效卷积核大小为 \(k_{\text{eff}} = k + (k-1)(d-1)\)

\[H_{out} = \left\lfloor \frac{H_{in} + 2p - d(k-1) - 1}{s} \right\rfloor + 1\]
Python
# 空洞卷积:3×3核,空洞率2,等效感受野5×5
dilated_conv = nn.Conv2d(64, 64, kernel_size=3, dilation=2, padding=2)

应用:语义分割(DeepLab系列)、语音合成(WaveNet)。

6.4 深度可分离卷积(Depthwise Separable Convolution)

深度可分离卷积

将标准卷积分解为两步,大幅减少参数量和计算量:

  1. 深度卷积(Depthwise):每个通道独立卷积(\(C_{in}\)\(k \times k \times 1\) 的卷积核)
  2. 逐点卷积(Pointwise)\(1 \times 1\) 卷积进行通道混合

计算量对比: - 标准卷积:\(C_{in} \times k^2 \times C_{out} \times H \times W\) - 深度可分离卷积:\(C_{in} \times k^2 \times H \times W + C_{in} \times C_{out} \times H \times W\) - 加速比约为:\(\frac{1}{C_{out}} + \frac{1}{k^2}\),对于典型的 \(k=3\),约为 8-9 倍

Python
class DepthwiseSeparableConv(nn.Module):
    """MobileNet中使用的深度可分离卷积"""
    def __init__(self, in_channels, out_channels, stride=1):
        super().__init__()
        self.depthwise = nn.Conv2d(
            in_channels, in_channels, kernel_size=3,
            stride=stride, padding=1, groups=in_channels, bias=False
        )
        self.bn1 = nn.BatchNorm2d(in_channels)
        self.pointwise = nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False)
        self.bn2 = nn.BatchNorm2d(out_channels)
        self.relu = nn.ReLU6(inplace=True)

    def forward(self, x):
        x = self.relu(self.bn1(self.depthwise(x)))
        x = self.relu(self.bn2(self.pointwise(x)))
        return x

这是MobileNet系列的核心模块,使CNN能在移动端和边缘设备上高效运行。


7. 数据增强技术

数据增强通过对训练数据施加随机变换,人工扩展数据集,提高模型泛化能力。

7.1 基础增强

Python
import torchvision.transforms as T

basic_transforms = T.Compose([
    T.RandomHorizontalFlip(p=0.5),           # 水平翻转
    T.RandomVerticalFlip(p=0.1),             # 垂直翻转(某些场景)
    T.RandomRotation(degrees=15),            # 随机旋转
    T.RandomCrop(32, padding=4),             # 随机裁剪
    T.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.1),  # 颜色抖动
    T.RandomGrayscale(p=0.1),               # 随机灰度
    T.RandomErasing(p=0.1),                  # 随机擦除(Cutout变体)
    T.ToTensor(),
    T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])

7.2 高级增强

Mixup(2018):将两张图像及其标签线性混合

\[\tilde{x} = \lambda x_i + (1 - \lambda) x_j, \quad \tilde{y} = \lambda y_i + (1 - \lambda) y_j\]

其中 \(\lambda \sim \text{Beta}(\alpha, \alpha)\)

Python
def mixup_data(x, y, alpha=0.2):
    lam = torch.distributions.Beta(alpha, alpha).sample()  # 链式调用,连续执行多个方法
    batch_size = x.size(0)
    index = torch.randperm(batch_size).to(x.device)
    mixed_x = lam * x + (1 - lam) * x[index]
    y_a, y_b = y, y[index]
    return mixed_x, y_a, y_b, lam

def mixup_criterion(criterion, pred, y_a, y_b, lam):
    return lam * criterion(pred, y_a) + (1 - lam) * criterion(pred, y_b)

CutMix(2019):将一张图片的某个区域替换为另一张图片,标签按面积比混合。比Mixup更能保留局部特征信息。

7.3 自动增强

  • AutoAugment:用强化学习搜索最佳增强策略
  • RandAugment:随机选择N种变换,每种强度M,简单高效
  • TrivialAugment:每次随机选一种变换和强度,效果惊人地好
Python
# RandAugment(torchvision内置)
from torchvision.transforms import RandAugment

train_transforms = T.Compose([
    T.RandomResizedCrop(224),
    T.RandomHorizontalFlip(),
    RandAugment(num_ops=2, magnitude=9),
    T.ToTensor(),
    T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])

8. 迁移学习

8.1 预训练模型

在大规模数据集(如ImageNet的128万图片)上预训练好的模型包含了通用的视觉特征,可以迁移到其他任务。

Python
import torchvision.models as models

# 加载预训练的ResNet-50
model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2)

8.2 特征提取(Feature Extraction)

冻结预训练模型的参数,只训练新的分类头:

Python
# 冻结所有层
for param in model.parameters():
    param.requires_grad = False

# 替换分类头
num_classes = 10
model.fc = nn.Linear(model.fc.in_features, num_classes)
# 只有model.fc的参数会被更新

适用场景:目标数据集较小,与预训练数据集领域相似。

8.3 微调(Fine-tuning)

解冻部分或全部预训练层,用较小的学习率训练:

Python
# 策略一:全部微调,低学习率
for param in model.parameters():
    param.requires_grad = True

optimizer = torch.optim.AdamW([
    {'params': model.fc.parameters(), 'lr': 1e-3},       # 分类头:较大学习率
    {'params': model.layer4.parameters(), 'lr': 1e-4},    # 深层:中等学习率
    {'params': model.layer3.parameters(), 'lr': 1e-5},    # 浅层:小学习率
], weight_decay=0.01)

# 策略二:逐步解冻(Gradual Unfreezing)
# 先训练分类头 → 解冻最后几层 → 解冻更多层

微调建议: - 数据量小 + 领域相似 → 特征提取 - 数据量小 + 领域不同 → 微调深层 - 数据量大 + 领域相似 → 全部微调 - 数据量大 + 领域不同 → 从头训练或全部微调


9. PyTorch实战:CIFAR-10分类

9.1 完整训练代码

Python
import torch
import torch.nn as nn
import torch.optim as optim
import torchvision
import torchvision.transforms as T
from torch.utils.data import DataLoader

# ==================== 1. 超参数 ====================
BATCH_SIZE = 128
EPOCHS = 100
LR = 0.1
WEIGHT_DECAY = 5e-4
DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

# ==================== 2. 数据准备 ====================
train_transform = T.Compose([
    T.RandomCrop(32, padding=4),
    T.RandomHorizontalFlip(),
    T.ToTensor(),
    T.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)),
])

test_transform = T.Compose([
    T.ToTensor(),
    T.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)),
])

train_dataset = torchvision.datasets.CIFAR10(
    root='./data', train=True, download=True, transform=train_transform)
test_dataset = torchvision.datasets.CIFAR10(
    root='./data', train=False, download=True, transform=test_transform)

train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=2)  # DataLoader批量加载数据,支持shuffle和多进程
test_loader = DataLoader(test_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=2)

# ==================== 3. 模型定义 ====================
class ResidualBlock(nn.Module):
    def __init__(self, in_channels, out_channels, stride=1):
        super().__init__()
        self.conv1 = nn.Conv2d(in_channels, out_channels, 3, stride, 1, bias=False)
        self.bn1 = nn.BatchNorm2d(out_channels)
        self.conv2 = nn.Conv2d(out_channels, out_channels, 3, 1, 1, bias=False)
        self.bn2 = nn.BatchNorm2d(out_channels)
        self.relu = nn.ReLU(inplace=True)

        self.shortcut = nn.Sequential()
        if stride != 1 or in_channels != out_channels:
            self.shortcut = nn.Sequential(
                nn.Conv2d(in_channels, out_channels, 1, stride, bias=False),
                nn.BatchNorm2d(out_channels)
            )

    def forward(self, x):
        out = self.relu(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        out += self.shortcut(x)
        return self.relu(out)

class ResNetCIFAR(nn.Module):
    """简化版ResNet,适用于CIFAR-10 (32×32图像)"""
    def __init__(self, num_classes=10):
        super().__init__()
        self.conv1 = nn.Conv2d(3, 64, 3, 1, 1, bias=False)
        self.bn1 = nn.BatchNorm2d(64)
        self.relu = nn.ReLU(inplace=True)

        self.layer1 = self._make_layer(64, 64, 2, stride=1)
        self.layer2 = self._make_layer(64, 128, 2, stride=2)
        self.layer3 = self._make_layer(128, 256, 2, stride=2)
        self.layer4 = self._make_layer(256, 512, 2, stride=2)

        self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
        self.fc = nn.Linear(512, num_classes)

        # Kaiming初始化
        for m in self.modules():
            if isinstance(m, nn.Conv2d):  # isinstance检查对象类型
                nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
            elif isinstance(m, nn.BatchNorm2d):
                nn.init.constant_(m.weight, 1)
                nn.init.constant_(m.bias, 0)

    def _make_layer(self, in_channels, out_channels, num_blocks, stride):
        layers = [ResidualBlock(in_channels, out_channels, stride)]
        for _ in range(1, num_blocks):
            layers.append(ResidualBlock(out_channels, out_channels, 1))
        return nn.Sequential(*layers)

    def forward(self, x):
        x = self.relu(self.bn1(self.conv1(x)))
        x = self.layer1(x)
        x = self.layer2(x)
        x = self.layer3(x)
        x = self.layer4(x)
        x = self.avgpool(x)
        x = x.view(x.size(0), -1)
        return self.fc(x)

# ==================== 4. 训练 ====================
model = ResNetCIFAR(num_classes=10).to(DEVICE)
criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(model.parameters(), lr=LR, momentum=0.9, weight_decay=WEIGHT_DECAY)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=EPOCHS)

best_acc = 0.0

for epoch in range(EPOCHS):
    # 训练
    model.train()
    train_loss = 0.0
    correct = 0
    total = 0

    for inputs, targets in train_loader:
        inputs, targets = inputs.to(DEVICE), targets.to(DEVICE)

        optimizer.zero_grad()  # 清零梯度,防止梯度累积
        outputs = model(inputs)
        loss = criterion(outputs, targets)
        loss.backward()  # 反向传播计算梯度
        optimizer.step()  # 根据梯度更新模型参数

        train_loss += loss.item()  # .item()将单元素张量转为Python数值
        _, predicted = outputs.max(1)
        total += targets.size(0)
        correct += predicted.eq(targets).sum().item()

    scheduler.step()
    train_acc = 100. * correct / total

    # 验证
    model.eval()
    correct = 0
    total = 0
    with torch.no_grad():  # 禁用梯度计算,节省内存
        for inputs, targets in test_loader:
            inputs, targets = inputs.to(DEVICE), targets.to(DEVICE)
            outputs = model(inputs)
            _, predicted = outputs.max(1)
            total += targets.size(0)
            correct += predicted.eq(targets).sum().item()

    test_acc = 100. * correct / total
    lr = optimizer.param_groups[0]['lr']

    if test_acc > best_acc:
        best_acc = test_acc
        torch.save(model.state_dict(), 'best_cifar10_resnet.pth')

    if (epoch + 1) % 10 == 0:
        print(f"Epoch [{epoch+1}/{EPOCHS}] LR: {lr:.6f} | "
              f"Train Acc: {train_acc:.2f}% | Test Acc: {test_acc:.2f}% | "
              f"Best: {best_acc:.2f}%")

print(f"\n训练完成!最佳测试准确率: {best_acc:.2f}%")

9.2 预期结果

使用上述ResNet配置,在CIFAR-10上通常可以达到 93%+ 的测试准确率。加上更强的数据增强(CutMix、MixUp)和更大的模型,可以达到 96%+


10. 可视化技术

10.1 特征图可视化

Python
import matplotlib.pyplot as plt

def visualize_feature_maps(model, image, layer_name='layer1'):
    """可视化中间层特征图"""
    activation = {}

    def hook_fn(module, input, output):
        activation[layer_name] = output.detach()  # detach()从计算图分离,不参与梯度计算

    # 注册hook
    layer = dict(model.named_modules())[layer_name]
    handle = layer.register_forward_hook(hook_fn)

    # 前向传播
    model.eval()
    with torch.no_grad():
        _ = model(image.unsqueeze(0).to(DEVICE))  # unsqueeze增加一个维度

    handle.remove()

    # 可视化前16个通道
    feat = activation[layer_name].cpu().squeeze(0)
    fig, axes = plt.subplots(4, 4, figsize=(10, 10))
    for i, ax in enumerate(axes.flat):  # enumerate同时获取索引和元素
        if i < feat.size(0):
            ax.imshow(feat[i], cmap='viridis')
        ax.axis('off')
    plt.suptitle(f'Feature Maps: {layer_name}')
    plt.tight_layout()
    plt.show()

10.2 Grad-CAM热力图

Grad-CAM(Gradient-weighted Class Activation Mapping)通过梯度信息生成类别激活热力图,帮助理解模型关注的区域。

Python
import numpy as np
import cv2

class GradCAM:
    """Grad-CAM可视化"""
    def __init__(self, model, target_layer):
        self.model = model
        self.gradients = None
        self.activations = None

        target_layer.register_forward_hook(self._save_activation)
        target_layer.register_full_backward_hook(self._save_gradient)

    def _save_activation(self, module, input, output):
        self.activations = output.detach()

    def _save_gradient(self, module, grad_input, grad_output):
        self.gradients = grad_output[0].detach()

    def generate(self, input_image, target_class=None):
        self.model.eval()
        output = self.model(input_image)

        if target_class is None:
            target_class = output.argmax(dim=1).item()

        self.model.zero_grad()
        output[0, target_class].backward()

        # 全局平均池化梯度作为权重
        weights = self.gradients.mean(dim=[2, 3], keepdim=True)  # (1, C, 1, 1)
        cam = (weights * self.activations).sum(dim=1, keepdim=True)  # (1, 1, H, W)
        cam = torch.relu(cam)
        cam = cam.squeeze().cpu().numpy()

        # 归一化到 [0,1]
        cam = (cam - cam.min()) / (cam.max() - cam.min() + 1e-8)
        return cam

# 使用示例
# grad_cam = GradCAM(model, model.layer4[-1])
# cam = grad_cam.generate(image.unsqueeze(0).to(DEVICE))
# 将cam上采样到原图大小,叠加显示

10.3 卷积核可视化

Python
def visualize_kernels(model):
    """可视化第一层卷积核"""
    kernels = model.conv1.weight.data.cpu()
    n = min(kernels.size(0), 64)
    fig, axes = plt.subplots(8, 8, figsize=(12, 12))
    for i, ax in enumerate(axes.flat):
        if i < n:
            kernel = kernels[i]
            # 归一化到 [0,1] 用于显示
            kernel = (kernel - kernel.min()) / (kernel.max() - kernel.min())
            if kernel.size(0) == 3:
                ax.imshow(kernel.permute(1, 2, 0))  # RGB
            else:
                ax.imshow(kernel[0], cmap='gray')    # 灰度
        ax.axis('off')
    plt.suptitle('First Layer Convolution Kernels')
    plt.tight_layout()
    plt.show()

11. 练习与自我检查

✏️ 练习题

  1. 尺寸计算:输入为 \(64 \times 64 \times 3\),依次经过 Conv(32, 5×5, s=1, p=2) → MaxPool(2×2, s=2) → Conv(64, 3×3, s=1, p=1) → MaxPool(2×2, s=2),求每层输出尺寸。

  2. 参数量计算:计算VGG-16中每一层卷积层的参数量,以及全连接层的参数量,验证总参数量约为138M。

  3. 实现Inception模块:用PyTorch实现一个包含1×1、3×3、5×5卷积和3×3最大池化的Inception模块。

  4. ResNet对比实验:分别训练一个20层普通CNN和20层ResNet,在CIFAR-10上对比训练曲线,验证残差连接的效果。

  5. 数据增强实验:在CIFAR-10上分别使用"无增强"、"基础增强"、"Mixup"、"CutMix"训练同一模型,对比测试准确率。

  6. 迁移学习实践:使用预训练ResNet-50对一个小数据集(如Flowers-102)进行迁移学习,对比特征提取和微调的效果。

  7. 可视化分析:用Grad-CAM可视化模型在正确分类和错误分类样本上的关注区域,分析模型的决策依据。

面试要点

Q1: 为什么CNN比全连接网络更适合处理图像? A: 局部连接利用空间局部性,权重共享大幅减少参数,平移不变性使模型更鲁棒。

Q2: 解释ResNet中残差连接如何解决梯度消失? A: 跳跃连接提供了梯度的直接传播路径,反向传播时梯度包含恒等项(+1),保证梯度不会消失。

Q3: 1×1卷积有什么用? A: 跨通道信息交互、升降维、增加非线性,是Inception和ResNet瓶颈块的核心组件。

Q4: 描述深度可分离卷积及其优势。 A: 将标准卷积分解为深度卷积(逐通道)和逐点卷积(1×1),计算量约减少 \(k^2\) 倍(\(k\)为核大小),是MobileNet的核心。

Q5: 如何选择迁移学习策略? A: 数据量小+领域相似→特征提取;数据量小+领域不同→微调深层;数据量大→全部微调或从头训练。

自我检查清单

  • 能手算卷积输出尺寸
  • 理解多通道卷积的参数量计算
  • 能说清LeNet → AlexNet → VGG → Inception → ResNet的演进
  • 理解残差连接为什么有效(数学角度)
  • 掌握1×1卷积、空洞卷积、深度可分离卷积的原理和应用
  • 能实现完整的CNN训练流程(数据增强 + 模型 + 训练 + 评估)
  • 理解迁移学习的不同策略
  • 能用Grad-CAM解释模型决策

下一章: 09-循环神经网络 — 从空间特征走向序列建模