Appearance
章节3:CNN卷积神经网络
学习目标
- 理解卷积与池化运算的核心概念(卷积核、步长、填充)
- 掌握 LeNet、AlexNet、VGG 等经典网络架构
- 理解 ResNet 残差网络如何解决梯度消失问题
- 学会迁移学习与模型微调的方法
- 完成 CIFAR-10 图像分类实战
3.1 卷积与池化运算
3.1.1 卷积运算
卷积层使用**卷积核(Kernel/Filter)**在输入上滑动计算点积,提取局部特征。
数学定义(二维卷积):
$$ S(i, j) = (I * K)(i, j) = \sum_{m} \sum_{n} I(i+m, j+n) K(m, n) $$
其中 $I$ 为输入图像,$K$ 为卷积核。
关键参数:
- 卷积核大小(Kernel Size):常见 3×3、5×7
- 步长(Stride):卷积核每次移动的像素数
- 填充(Padding):在输入边缘补零,控制输出尺寸
输出尺寸公式:
$$ \text{Output Size} = \left\lfloor \frac{\text{Input Size} - \text{Kernel Size} + 2 \times \text{Padding}}{\text{Stride}} \right\rfloor + 1 $$
python
import torch
import torch.nn as nn
# 二维卷积示例
conv = nn.Conv2d(
in_channels=3, # 输入通道数(RGB)
out_channels=16, # 输出通道数(卷积核个数)
kernel_size=3, # 卷积核大小
stride=1, # 步长
padding=1 # 填充(保持尺寸不变)
)
x = torch.randn(32, 3, 32, 32) # (batch, channel, height, width)
y = conv(x)
print(f"输入形状: {x.shape} → 输出形状: {y.shape}")
# torch.Size([32, 16, 32, 32])3.1.2 池化运算(Pooling)
池化层通过下采样降低特征图尺寸,减少参数量并增强平移不变性。
- 最大池化(Max Pooling):取窗口内最大值
- 平均池化(Average Pooling):取窗口内平均值
python
# 最大池化
maxpool = nn.MaxPool2d(kernel_size=2, stride=2)
x = torch.randn(32, 16, 32, 32)
y = maxpool(x)
print(f"池化后: {y.shape}") # torch.Size([32, 16, 16, 16])
# 自适应平均池化(输出指定尺寸)
avgpool = nn.AdaptiveAvgPool2d((1, 1)) # 全局平均池化
y = avgpool(x)
print(f"全局平均池化: {y.shape}") # torch.Size([32, 16, 1, 1])3.2 经典网络架构
3.2.1 LeNet-5(1998)
Yann LeCun 提出的首个成功 CNN,用于手写数字识别。
结构: Conv → Pool → Conv → Pool → FC → FC → FC
python
class LeNet5(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(1, 6, kernel_size=5, padding=2), # 28→28
nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2), # 28→14
nn.Conv2d(6, 16, kernel_size=5), # 14→10
nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2), # 10→5
)
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)
x = self.classifier(x)
return x
model = LeNet5()
print(model)3.2.2 AlexNet(2012)
AlexNet 在 ImageNet 上大幅领先,开创了深度学习时代。使用 ReLU、Dropout、数据增强。
关键创新: ReLU 激活、GPU 并行训练、局部响应归一化(LRN)。
python
class AlexNet(nn.Module):
def __init__(self, num_classes=1000):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(3, 64, kernel_size=11, stride=4, padding=2), # 227→55
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=3, stride=2), # 55→27
nn.Conv2d(64, 192, kernel_size=5, padding=2), # 27→27
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=3, stride=2), # 27→13
nn.Conv2d(192, 384, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.Conv2d(384, 256, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.Conv2d(256, 256, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=3, stride=2), # 13→6
)
self.classifier = nn.Sequential(
nn.Dropout(p=0.5),
nn.Linear(256 * 6 * 6, 4096),
nn.ReLU(inplace=True),
nn.Dropout(p=0.5),
nn.Linear(4096, 4096),
nn.ReLU(inplace=True),
nn.Linear(4096, num_classes)
)
def forward(self, x):
x = self.features(x)
x = x.view(x.size(0), -1)
x = self.classifier(x)
return x3.2.3 VGG-16(2014)
VGG 证明**堆叠小卷积核(3×3)**可以取得更深更好的效果。
特点: 全部使用 3×3 卷积、2×2 池化、结构规整。
python
class VGG16(nn.Module):
def __init__(self, num_classes=1000):
super().__init__()
self.features = nn.Sequential(
# Block 1: 2×Conv3-64
nn.Conv2d(3, 64, 3, padding=1), nn.ReLU(True),
nn.Conv2d(64, 64, 3, padding=1), nn.ReLU(True),
nn.MaxPool2d(2, 2),
# Block 2: 2×Conv3-128
nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(True),
nn.Conv2d(128, 128, 3, padding=1), nn.ReLU(True),
nn.MaxPool2d(2, 2),
# Block 3: 3×Conv3-256
nn.Conv2d(128, 256, 3, padding=1), nn.ReLU(True),
nn.Conv2d(256, 256, 3, padding=1), nn.ReLU(True),
nn.Conv2d(256, 256, 3, padding=1), nn.ReLU(True),
nn.MaxPool2d(2, 2),
# Block 4: 3×Conv3-512
nn.Conv2d(256, 512, 3, padding=1), nn.ReLU(True),
nn.Conv2d(512, 512, 3, padding=1), nn.ReLU(True),
nn.Conv2d(512, 512, 3, padding=1), nn.ReLU(True),
nn.MaxPool2d(2, 2),
# Block 5: 3×Conv3-512
nn.Conv2d(512, 512, 3, padding=1), nn.ReLU(True),
nn.Conv2d(512, 512, 3, padding=1), nn.ReLU(True),
nn.Conv2d(512, 512, 3, padding=1), nn.ReLU(True),
nn.MaxPool2d(2, 2),
)
self.classifier = nn.Sequential(
nn.Linear(512 * 7 * 7, 4096), nn.ReLU(True), nn.Dropout(),
nn.Linear(4096, 4096), nn.ReLU(True), nn.Dropout(),
nn.Linear(4096, num_classes)
)
def forward(self, x):
x = self.features(x)
x = x.view(x.size(0), -1)
x = self.classifier(x)
return x3.3 ResNet 残差网络
3.3.1 梯度消失问题
网络越深,梯度在反向传播中连乘导致趋近于 0,使浅层无法有效学习。
3.3.2 残差连接(Skip Connection)
ResNet(He et al., 2015)引入恒等快捷连接,让梯度可以直通浅层。
核心思想: 让网络学习残差 $F(x) = H(x) - x$ 而非直接映射 $H(x)$。
$$ H(x) = F(x) + x $$
python
class BasicBlock(nn.Module):
"""ResNet 基础残差块(用于 ResNet-18/34)"""
expansion = 1
def __init__(self, in_channels, out_channels, stride=1):
super().__init__()
self.conv1 = nn.Conv2d(in_channels, out_channels, 3, stride, padding=1)
self.bn1 = nn.BatchNorm2d(out_channels)
self.conv2 = nn.Conv2d(out_channels, out_channels * self.expansion, 3, padding=1)
self.bn2 = nn.BatchNorm2d(out_channels * self.expansion)
self.relu = nn.ReLU(inplace=True)
# 当维度不匹配时,用 1×1 卷积调整
self.shortcut = nn.Sequential()
if stride != 1 or in_channels != out_channels * self.expansion:
self.shortcut = nn.Sequential(
nn.Conv2d(in_channels, out_channels * self.expansion, 1, stride),
nn.BatchNorm2d(out_channels * self.expansion)
)
def forward(self, x):
identity = self.shortcut(x)
out = self.conv1(x)
out = self.bn1(out)
out = self.relu(out)
out = self.conv2(out)
out = self.bn2(out)
out += identity # 残差连接(核心)
out = self.relu(out)
return out
class ResNet18(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.in_channels = 64
self.conv1 = nn.Conv2d(3, 64, 7, stride=2, padding=3)
self.bn1 = nn.BatchNorm2d(64)
self.relu = nn.ReLU(inplace=True)
self.maxpool = nn.MaxPool2d(3, stride=2, padding=1)
self.layer1 = self._make_layer(64, 2, stride=1)
self.layer2 = self._make_layer(128, 2, stride=2)
self.layer3 = self._make_layer(256, 2, stride=2)
self.layer4 = self._make_layer(512, 2, stride=2)
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(512, num_classes)
def _make_layer(self, out_channels, blocks, stride):
layers = []
layers.append(BasicBlock(self.in_channels, out_channels, stride))
self.in_channels = out_channels * BasicBlock.expansion
for _ in range(1, blocks):
layers.append(BasicBlock(self.in_channels, out_channels))
return nn.Sequential(*layers)
def forward(self, x):
x = self.conv1(x)
x = self.bn1(x)
x = self.relu(x)
x = self.maxpool(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)
x = self.fc(x)
return x
model = ResNet18()
print(f"ResNet-18 参数量: {sum(p.numel() for p in model.parameters()):,}")3.4 迁移学习与模型微调
3.4.1 为什么迁移学习?
训练大规模 CNN(如 ResNet)需要大量数据和算力。迁移学习将预训练模型的知识迁移到新任务上。
3.4.2 微调策略
- 特征提取:冻结所有卷积层,仅训练新分类头
- 全模型微调:以预训练权重为初始值,所有层参与训练
- 渐进解冻:自顶向下逐步解冻层
python
import torchvision.models as models
# 加载预训练 ResNet-18
resnet = models.resnet18(pretrained=True)
# 冻结所有参数
for param in resnet.parameters():
param.requires_grad = False
# 替换最后一层(适配新数据集类别数)
num_ftrs = resnet.fc.in_features
resnet.fc = nn.Linear(num_ftrs, 10) # CIFAR-10 共10类
# 仅训练新分类头
optimizer = optim.Adam(resnet.fc.parameters(), lr=0.001)
# 或者在有限轮次后解冻全部进行微调
# for param in resnet.parameters():
# param.requires_grad = True
# optimizer = optim.Adam(resnet.parameters(), lr=0.0001) # 更小的学习率3.5 图像分类实战:CIFAR-10
python
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
import time
# 1. 数据准备
transform = transforms.Compose([
transforms.RandomHorizontalFlip(), # 数据增强
transforms.RandomCrop(32, padding=4),
transforms.ToTensor(),
transforms.Normalize((0.4914, 0.4822, 0.4465), # CIFAR-10 均值/标准差
(0.2023, 0.1994, 0.2010))
])
train_dataset = datasets.CIFAR10(
root='./data', train=True, download=True, transform=transform
)
test_dataset = datasets.CIFAR10(
root='./data', train=False, download=True, transform=transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.4914, 0.4822, 0.4465),
(0.2023, 0.1994, 0.2010))
])
)
train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True, num_workers=2)
test_loader = DataLoader(test_dataset, batch_size=128, shuffle=False, num_workers=2)
# 2. 定义改进版 CNN(比 LeNet 更强)
class CIFAR10CNN(nn.Module):
def __init__(self):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(3, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),
nn.Conv2d(64, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),
nn.MaxPool2d(2, 2), nn.Dropout2d(0.25),
nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(),
nn.Conv2d(128, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(),
nn.MaxPool2d(2, 2), nn.Dropout2d(0.25),
nn.Conv2d(128, 256, 3, padding=1), nn.BatchNorm2d(256), nn.ReLU(),
nn.Conv2d(256, 256, 3, padding=1), nn.BatchNorm2d(256), nn.ReLU(),
nn.AdaptiveAvgPool2d((1, 1))
)
self.classifier = nn.Sequential(
nn.Dropout(0.5),
nn.Linear(256, 10)
)
def forward(self, x):
x = self.features(x)
x = x.view(x.size(0), -1)
x = self.classifier(x)
return x
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = CIFAR10CNN().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50)
# 3. 训练
def train_one_epoch(model, loader, optimizer, criterion, device):
model.train()
total_loss = 0.0
correct = 0
total = 0
for X, y in loader:
X, y = X.to(device), y.to(device)
optimizer.zero_grad()
outputs = model(X)
loss = criterion(outputs, y)
loss.backward()
optimizer.step()
total_loss += loss.item()
_, predicted = torch.max(outputs, 1)
total += y.size(0)
correct += (predicted == y).sum().item()
return total_loss / len(loader), 100 * correct / total
def evaluate(model, loader, criterion, device):
model.eval()
total_loss = 0.0
correct = 0
total = 0
with torch.no_grad():
for X, y in loader:
X, y = X.to(device), y.to(device)
outputs = model(X)
loss = criterion(outputs, y)
total_loss += loss.item()
_, predicted = torch.max(outputs, 1)
total += y.size(0)
correct += (predicted == y).sum().item()
return total_loss / len(loader), 100 * correct / total
# 训练循环
epochs = 50
best_acc = 0.0
for epoch in range(epochs):
start = time.time()
train_loss, train_acc = train_one_epoch(model, train_loader, optimizer, criterion, device)
test_loss, test_acc = evaluate(model, test_loader, criterion, device)
scheduler.step()
if test_acc > best_acc:
best_acc = test_acc
torch.save(model.state_dict(), 'cifar10_best.pth')
if (epoch + 1) % 5 == 0:
print(f"Epoch {epoch+1:2d}/{epochs} | "
f"Train Loss: {train_loss:.4f} Acc: {train_acc:.2f}% | "
f"Test Loss: {test_loss:.4f} Acc: {test_acc:.2f}% | "
f"Best: {best_acc:.2f}% | "
f"Time: {time.time()-start:.1f}s")
print(f"最终测试准确率: {best_acc:.2f}%")小结
| 概念 | 关键要点 |
|---|---|
| 卷积 | 滑窗点积提取特征,核大小/步长/填充控制输出 |
| 池化 | 下采样降维,MaxPooling 常用 |
| LeNet-5 | 最早的 CNN,手写数字识别 |
| AlexNet | 深度 CNN 先驱,ReLU + Dropout |
| VGG-16 | 规整的 3×3 卷积堆叠 |
| ResNet | 残差连接解决梯度消失 |
| 迁移学习 | 利用预训练模型加速新任务 |
| CIFAR-10 | 32×32 小图像分类基准数据集 |
练习
- Conv 计算:输入
32×32×3,使用 10 个5×5卷积核,stride=1, padding=2,求输出尺寸。 - 实现 LeNet:在 MNIST 上实现 LeNet-5,达到 99%+ 准确率。
- 残差网络:将练习 2 的 LeNet 加上残差连接,观察训练曲线变化。
- 迁移学习:使用预训练的 ResNet-18 在 CIFAR-10 上微调,对比从头训练的效果。
- 特征可视化:提取 CNN 中间层特征图并可视化,理解卷积核学到了什么。