08-卷积神经网络¶
学习时间: 约8-10小时 难度级别: ⭐⭐⭐ 中级 前置知识: 神经网络基础、反向传播算法、正则化技术、优化器 学习目标: 深入理解卷积神经网络的原理与核心组件,掌握经典CNN架构的设计思想,能够用PyTorch实现图像分类任务
🎯 学习目标¶
- 理解卷积操作的数学原理(互相关运算、卷积核、步长、填充)
- 掌握卷积层输入输出尺寸的计算公式
- 理解池化层的作用与不同类型
- 熟悉CNN的典型结构与数据流
- 深入了解经典CNN架构及其创新点(LeNet → EfficientNet 演进)
- 掌握1×1卷积、转置卷积、空洞卷积、深度可分离卷积等高级技术
- 理解数据增强和迁移学习的实践方法
- 能够用PyTorch完成CIFAR-10图像分类任务
目录¶
- 1. 卷积操作原理
- 2. 卷积层数学表达
- 3. 池化层
- 4. CNN典型结构
- 5. 经典架构详解
- 6. 高级卷积技术
- 7. 数据增强技术
- 8. 迁移学习
- 9. PyTorch实战:CIFAR-10分类
- 10. 可视化技术
- 11. 练习与自我检查
1. 卷积操作原理¶
1.1 从全连接到卷积¶
全连接神经网络处理图像存在两个核心问题:
- 参数爆炸:一张 \(224 \times 224 \times 3\) 的图像输入全连接层,仅一个神经元就需要 \(224 \times 224 \times 3 = 150{,}528\) 个权重
- 忽略空间结构:将图像展平为一维向量,丢失了像素间的空间关系
卷积神经网络通过两个关键特性解决这些问题:局部连接(每个神经元只看一小块区域)和权重共享(同一个卷积核在整张图像上滑动)。
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)\) 个元素为:
其中 \(b\) 是偏置项。
直觉理解:卷积核(滤波器)像一个"窗口"在输入图像上滑动,每到一个位置就与覆盖区域做逐元素乘法再求和,得到一个标量输出值。
1.3 卷积核(Filter / Kernel)¶
卷积核是一组可学习的权重矩阵,不同的卷积核可以检测不同的特征:
- 边缘检测核:检测水平/垂直/对角边缘
- 模糊核:平滑图像
- 锐化核:增强图像细节
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}\) 个这样的卷积核,卷积层总参数量为:
其中 \(+1\) 是偏置项。
2. 卷积层数学表达¶
2.1 输出尺寸计算公式¶
给定输入尺寸 \(H_{in} \times W_{in}\),卷积核大小 \(k\),步长 \(s\),填充 \(p\),输出尺寸为:
计算示例:
| 场景 | \(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\) 层的感受野为:
例如,3层 \(3 \times 3\) 卷积(步长均为1)的感受野为 \(1 + 2 + 2 + 2 = 7\),这就是VGGNet用多个小卷积核替代大卷积核的数学依据。
2.3 计算量分析¶
单层卷积的浮点运算量(FLOPs):
其中因子2来自乘法和加法各一次。
3. 池化层¶
池化层用于降低特征图的空间维度,减少参数量和计算量,同时提供一定的平移不变性。
3.1 最大池化(Max Pooling)¶
取池化窗口中的最大值,保留最显著的特征:
常用配置:\(2 \times 2\) 窗口,步长2,将特征图尺寸减半。
3.2 平均池化(Average Pooling)¶
取池化窗口中的平均值,保留所有特征的概要信息:
3.3 全局平均池化(Global Average Pooling, GAP)¶
对每个通道的整个特征图取平均值,输出一个标量。将 \(C \times H \times W\) 的特征图变为 \(C \times 1 \times 1\):
优点:无可学习参数,可替代全连接层,减少过拟合风险。被GoogLeNet和ResNet等广泛采用。
# 全局平均池化
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 池化层的输出尺寸¶
池化层的输出尺寸计算公式与卷积层相同(无填充时):
4. CNN典型结构¶
CNN架构¶
4.1 经典数据流¶
一个典型的CNN遵循以下结构范式:
输入图像
→ [卷积层 → 激活函数(ReLU) → 池化层] × N (特征提取)
→ 展平 (Flatten)
→ [全连接层 → 激活函数] × M (分类决策)
→ 输出层 (Softmax/Sigmoid)
4.2 各层的作用¶
| 层类型 | 作用 | 特点 |
|---|---|---|
| 卷积层 | 提取局部特征 | 权重共享,局部连接 |
| 激活函数 | 引入非线性 | ReLU最常用 |
| 池化层 | 下采样,减少参数 | 提供平移不变性 |
| Batch Norm | 稳定训练,加速收敛 | 现代CNN标配 |
| 全连接层 | 综合特征,输出分类 | 参数量大 |
| Dropout | 防止过拟合 | 训练时随机丢弃 |
4.3 特征层次¶
CNN自底向上学习越来越抽象的特征:
- 浅层(第1-2层):边缘、纹理、颜色
- 中层(第3-4层):局部形状、纹理组合
- 深层(第5层及以上):物体部件、语义特征
5. 经典架构详解¶
5.1 LeNet-5(1998, Yann LeCun)¶
结构:
输入 (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,在当时的计算条件下即可训练
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结构:
输入 (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模块结构:
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)¶
核心创新——残差连接:解决了深层网络的退化问题(更深的网络反而效果更差)。
残差块(Residual Block):
其中 \(\mathcal{F}\) 是残差映射(两层或三层卷积),\(\mathbf{x}\) 是恒等映射(跳跃连接/shortcut)。
为什么有效——梯度高速公路:
反向传播时梯度可以通过跳跃连接直接回传:
加号右边的 \(1\) 保证梯度至少为1,不会消失。
基本块 vs 瓶颈块:
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)¶
核心创新——密集连接:每一层都与前面所有层连接,实现最大程度的特征复用。
其中 \([\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\))。
满足约束:\(\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卷积的作用¶
\(1 \times 1\) 卷积看似简单,实际功能强大:
- 降维:减少通道数,降低计算量(如Inception中的使用)
- 升维:增加通道数,增强表达能力
- 跨通道交互:对每个空间位置的所有通道进行线性组合
- 增加非线性:配合激活函数,增加网络深度而不改变空间维度
等价于对每个空间位置的通道向量做一次全连接变换,因此也称为逐点卷积(Pointwise Convolution)。
6.2 转置卷积(Transposed Convolution)¶
转置卷积用于上采样(upsampling),将低分辨率特征图恢复到高分辨率,常用于语义分割和生成模型。
转置卷积的输出尺寸:
# 转置卷积:将特征图放大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)\):
# 空洞卷积: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)¶
将标准卷积分解为两步,大幅减少参数量和计算量:
- 深度卷积(Depthwise):每个通道独立卷积(\(C_{in}\) 个 \(k \times k \times 1\) 的卷积核)
- 逐点卷积(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 倍
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 基础增强¶
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):将两张图像及其标签线性混合
其中 \(\lambda \sim \text{Beta}(\alpha, \alpha)\)。
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:每次随机选一种变换和强度,效果惊人地好
# 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万图片)上预训练好的模型包含了通用的视觉特征,可以迁移到其他任务。
import torchvision.models as models
# 加载预训练的ResNet-50
model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2)
8.2 特征提取(Feature Extraction)¶
冻结预训练模型的参数,只训练新的分类头:
# 冻结所有层
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)¶
解冻部分或全部预训练层,用较小的学习率训练:
# 策略一:全部微调,低学习率
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 完整训练代码¶
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 特征图可视化¶
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)通过梯度信息生成类别激活热力图,帮助理解模型关注的区域。
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 卷积核可视化¶
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. 练习与自我检查¶
✏️ 练习题¶
-
尺寸计算:输入为 \(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),求每层输出尺寸。
-
参数量计算:计算VGG-16中每一层卷积层的参数量,以及全连接层的参数量,验证总参数量约为138M。
-
实现Inception模块:用PyTorch实现一个包含1×1、3×3、5×5卷积和3×3最大池化的Inception模块。
-
ResNet对比实验:分别训练一个20层普通CNN和20层ResNet,在CIFAR-10上对比训练曲线,验证残差连接的效果。
-
数据增强实验:在CIFAR-10上分别使用"无增强"、"基础增强"、"Mixup"、"CutMix"训练同一模型,对比测试准确率。
-
迁移学习实践:使用预训练ResNet-50对一个小数据集(如Flowers-102)进行迁移学习,对比特征提取和微调的效果。
-
可视化分析:用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-循环神经网络 — 从空间特征走向序列建模










