第7章:卷积网络(Convolutional Networks)
一、导读
1.1 本章学习目标
第7章讲解卷积神经网络(CNN),这是深度学习在计算机视觉领域取得突破性成功的核心架构。CNN通过卷积操作利用图像的局部性和平移不变性,大幅减少了参数量,同时保持了强大的特征提取能力。
本章的核心学习目标包括:
- 理解卷积操作的原理:局部连接、权值共享、平移不变性
- 掌握CNN的基本组件:卷积层、池化层、全连接层
- 理解经典CNN架构:LeNet、AlexNet、VGG、ResNet的演进
- 掌握感受野的概念:如何计算和理解感受野
- 理解CNN中的特征层次:从低级边缘到高级语义
- 了解现代CNN设计原则
1.2 为什么本章如此重要
CNN是深度学习的标志性成功。从2012年AlexNet赢得ImageNet竞赛开始,CNN彻底改变了计算机视觉领域。直到今天,CNN仍然是许多视觉任务的首选架构。
- 图像分类:CNN是图像分类的基础架构
- 目标检测:Faster R-CNN、YOLO等检测器都基于CNN
- 语义分割:U-Net、DeepLab等分割模型使用CNN
- 生成模型:StyleGAN等生成模型使用CNN
理解CNN的原理和设计,是进入计算机视觉领域的必经之路。
1.3 核心问题引导
为什么全连接网络不适合处理图像?参数量和泛化能力有什么问题?
卷积操作的三个关键特性是什么?它们如何减少参数?
池化层的作用是什么?为什么现代网络越来越少使用池化层?
感受野如何影响模型的感知范围?如何计算感受野?
残差连接为什么能训练极深的网络?它解决了什么问题?
从LeNet到ResNet,CNN架构的演进遵循了什么规律?
二、核心概念详解
2.1 卷积操作
2.1.1 从全连接到卷积
import torch
import torch.nn as nn
# 全连接层处理图像的问题
# 28x28灰度图像 -> 784维输入
# 如果第一层有256个神经元:784 * 256 = 200,704 参数
# 这些参数没有利用图像的局部结构
# 卷积层:局部连接 + 权值共享
# 3x3卷积核,64个滤波器:3*3*1*64 + 64 = 640 参数
# 无论输入多大,参数量不变
# 2D卷积示例
conv = nn.Conv2d(in_channels=1, out_channels=64, kernel_size=3, padding=1)
x = torch.randn(1, 1, 28, 28) # 批量1,通道1,高28,宽28
out = conv(x)
print(f"输入形状: {x.shape}")
print(f"输出形状: {out.shape}")
print(f"参数量: {sum(p.numel() for p in conv.parameters())}")2.1.2 卷积的数学定义
import numpy as np
def conv2d_manual(image, kernel, stride=1, padding=0):
"""手动实现2D卷积"""
if padding > 0:
image = np.pad(image, padding, mode='constant')
h, w = image.shape
kh, kw = kernel.shape
oh = (h - kh) // stride + 1
ow = (w - kw) // stride + 1
output = np.zeros((oh, ow))
for i in range(oh):
for j in range(ow):
region = image[i*stride:i*stride+kh, j*stride:j*stride+kw]
output[i, j] = np.sum(region * kernel)
return output
# 示例:边缘检测
image = np.array([
[0, 0, 0, 0, 0],
[0, 1, 1, 1, 0],
[0, 1, 1, 1, 0],
[0, 1, 1, 1, 0],
[0, 0, 0, 0, 0]
], dtype=float)
# 水平边缘检测核
kernel_h = np.array([[-1, -1, -1], [0, 0, 0], [1, 1, 1]], dtype=float)
# 垂直边缘检测核
kernel_v = np.array([[-1, 0, 1], [-1, 0, 1], [-1, 0, 1]], dtype=float)
out_h = conv2d_manual(image, kernel_h, padding=1)
out_v = conv2d_manual(image, kernel_v, padding=1)
fig, axes = plt.subplots(1, 3, figsize=(12, 4))
axes[0].imshow(image, cmap='gray')
axes[0].set_title('原始图像')
axes[1].imshow(out_h, cmap='gray')
axes[1].set_title('水平边缘')
axes[2].imshow(out_v, cmap='gray')
axes[2].set_title('垂直边缘')
for ax in axes:
ax.axis('off')
plt.tight_layout()
plt.savefig('convolution_edges.png', dpi=150)
plt.show()2.1.3 卷积的关键特性
# 1. 局部连接:每个输出只依赖输入的局部区域
# 2. 权值共享:同一个卷积核在所有位置共享参数
# 3. 平移不变性:同一个特征在任何位置都能被检测到
# 参数量对比
input_size = 224 * 224 * 3 # ImageNet图像
fc_neurons = 4096
# 全连接层
fc_params = input_size * fc_neurons + fc_neurons
print(f"全连接层参数: {fc_params:,}") # 约6.2亿
# 卷积层(等效感受野)
conv_params = 3 * 3 * 3 * 64 + 64 # 3x3卷积,64个滤波器
print(f"卷积层参数: {conv_params}") # 17922.2 池化层
import torch
import torch.nn as nn
# 最大池化
max_pool = nn.MaxPool2d(kernel_size=2, stride=2)
x = torch.randn(1, 64, 56, 56)
out = max_pool(x)
print(f"MaxPool: {x.shape} -> {out.shape}")
# 平均池化
avg_pool = nn.AvgPool2d(kernel_size=2, stride=2)
out = avg_pool(x)
print(f"AvgPool: {x.shape} -> {out.shape}")
# 全局平均池化(替代全连接层)
global_avg_pool = nn.AdaptiveAvgPool2d((1, 1))
out = global_avg_pool(x)
print(f"GlobalAvgPool: {x.shape} -> {out.shape}")2.3 经典CNN架构
2.3.1 LeNet-5(1998)
class LeNet5(nn.Module):
def __init__(self):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(1, 6, 5), # 28 -> 24
nn.Tanh(),
nn.AvgPool2d(2, 2), # 24 -> 12
nn.Conv2d(6, 16, 5), # 12 -> 8
nn.Tanh(),
nn.AvgPool2d(2, 2), # 8 -> 4
)
self.classifier = nn.Sequential(
nn.Linear(16 * 4 * 4, 120),
nn.Tanh(),
nn.Linear(120, 84),
nn.Tanh(),
nn.Linear(84, 10),
)
def forward(self, x):
x = self.features(x)
x = x.view(x.size(0), -1)
x = self.classifier(x)
return x
model = LeNet5()
print(f"LeNet-5 参数量: {sum(p.numel() for p in model.parameters()):,}")2.3.2 ResNet(2015)
class ResidualBlock(nn.Module):
def __init__(self, channels):
super().__init__()
self.conv1 = nn.Conv2d(channels, channels, 3, padding=1)
self.bn1 = nn.BatchNorm2d(channels)
self.conv2 = nn.Conv2d(channels, channels, 3, padding=1)
self.bn2 = nn.BatchNorm2d(channels)
def forward(self, x):
residual = x
out = torch.relu(self.bn1(self.conv1(x)))
out = self.bn2(self.conv2(out))
out += residual # 残差连接
return torch.relu(out)
class SimpleResNet(nn.Module):
def __init__(self, n_blocks=3):
super().__init__()
self.conv1 = nn.Conv2d(3, 64, 7, stride=2, padding=3)
self.bn1 = nn.BatchNorm2d(64)
self.pool = nn.MaxPool2d(3, stride=2, padding=1)
self.res_blocks = nn.Sequential(*[ResidualBlock(64) for _ in range(n_blocks)])
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(64, 1000)
def forward(self, x):
x = self.pool(torch.relu(self.bn1(self.conv1(x))))
x = self.res_blocks(x)
x = self.avgpool(x)
x = x.view(x.size(0), -1)
x = self.fc(x)
return x
model = SimpleResNet()
print(f"SimpleResNet 参数量: {sum(p.numel() for p in model.parameters()):,}")2.3.3 残差连接的作用
# 残差连接解决梯度消失问题
# 前向:y = F(x) + x,梯度可以直接流过
# 反向:∂L/∂x = ∂L/∂y * (1 + ∂F/∂x),梯度不会消失
import torch
import torch.nn as nn
# 对比有无残差连接的梯度流
class WithoutResidual(nn.Module):
def __init__(self, n_layers):
super().__init__()
layers = []
for _ in range(n_layers):
layers.extend([nn.Conv2d(64, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU()])
self.net = nn.Sequential(*layers)
def forward(self, x):
return self.net(x)
class WithResidual(nn.Module):
def __init__(self, n_layers):
super().__init__()
self.blocks = nn.ModuleList()
for _ in range(n_layers):
self.blocks.append(nn.Sequential(
nn.Conv2d(64, 64, 3, padding=1),
nn.BatchNorm2d(64),
nn.ReLU(),
nn.Conv2d(64, 64, 3, padding=1),
nn.BatchNorm2d(64),
))
def forward(self, x):
for block in self.blocks:
x = torch.relu(block(x) + x)
return x
# 测试梯度流
for depth in [5, 10, 20]:
model_no_res = WithoutResidual(depth)
model_res = WithResidual(depth)
x = torch.randn(1, 64, 32, 32, requires_grad=True)
# 无残差
out = model_no_res(x)
out.sum().backward()
grad_no_res = x.grad.norm().item()
# 有残差
x.grad = None
out = model_res(x)
out.sum().backward()
grad_res = x.grad.norm().item()
print(f"Depth={depth:2d}: 无残差梯度={grad_no_res:.6f}, 有残差梯度={grad_res:.6f}")2.4 感受野
def compute_receptive_field(kernel_sizes, strides, paddings):
"""计算感受野"""
rf = 1
stride_product = 1
for k, s, p in zip(kernel_sizes, strides, paddings):
rf = rf + (k - 1) * stride_product
stride_product *= s
return rf
# VGG-16的感受野计算
vgg_config = [
(3, 1, 1), (3, 1, 1), (2, 2, 0), # Block 1
(3, 1, 1), (3, 1, 1), (2, 2, 0), # Block 2
(3, 1, 1), (3, 1, 1), (3, 1, 1), (2, 2, 0), # Block 3
(3, 1, 1), (3, 1, 1), (3, 1, 1), (2, 2, 0), # Block 4
(3, 1, 1), (3, 1, 1), (3, 1, 1), (2, 2, 0), # Block 5
]
kernel_sizes = [c[0] for c in vgg_config]
strides = [c[1] for c in vgg_config]
paddings = [c[2] for c in vgg_config]
rf = compute_receptive_field(kernel_sizes, strides, paddings)
print(f"VGG-16的感受野: {rf}x{rf} 像素")三、实战案例
3.1 案例一:CIFAR-10图像分类
import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, transforms, models
from torch.utils.data import DataLoader
# 数据增强
train_transform = transforms.Compose([
transforms.RandomCrop(32, padding=4),
transforms.RandomHorizontalFlip(),
transforms.ToTensor(),
transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)),
])
test_transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)),
])
train_dataset = datasets.CIFAR10('./data', train=True, download=True, transform=train_transform)
test_dataset = datasets.CIFAR10('./data', train=False, download=True, transform=test_transform)
train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True, num_workers=2)
test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False, num_workers=2)
# 自定义CNN
class CustomCNN(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.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.Conv2d(128, 256, 3, padding=1), nn.BatchNorm2d(256), nn.ReLU(),
nn.Conv2d(256, 256, 3, padding=1), nn.BatchNorm2d(256), nn.ReLU(),
nn.MaxPool2d(2, 2),
)
self.classifier = nn.Sequential(
nn.AdaptiveAvgPool2d((1, 1)),
nn.Flatten(),
nn.Linear(256, 10),
)
def forward(self, x):
x = self.features(x)
x = self.classifier(x)
return x
# 训练
model = CustomCNN().cuda()
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50)
for epoch in range(50):
model.train()
train_loss, correct, total = 0, 0, 0
for data, target in train_loader:
data, target = data.cuda(), target.cuda()
optimizer.zero_grad()
output = model(data)
loss = criterion(output, target)
loss.backward()
optimizer.step()
train_loss += loss.item()
pred = output.argmax(1)
correct += pred.eq(target).sum().item()
total += target.size(0)
scheduler.step()
if (epoch + 1) % 10 == 0:
model.eval()
test_correct, test_total = 0, 0
with torch.no_grad():
for data, target in test_loader:
data, target = data.cuda(), target.cuda()
output = model(data)
pred = output.argmax(1)
test_correct += pred.eq(target).sum().item()
test_total += target.size(0)
print(f"Epoch {epoch+1}: Train Acc={correct/total:.4f}, Test Acc={test_correct/test_total:.4f}")3.2 案例二:可视化CNN学到的特征
import torch
import torch.nn as nn
from torchvision import models
# 加载预训练模型
model = models.vgg16(pretrained=True).features
# 可视化第一层的卷积核
with torch.no_grad():
weights = model[0].weight.cpu().numpy() # 64 x 3 x 3 x 3
fig, axes = plt.subplots(8, 8, figsize=(12, 12))
for i, ax in enumerate(axes.flat):
if i < weights.shape[0]:
kernel = weights[i].transpose(1, 2, 0) # 3x3x3
kernel = (kernel - kernel.min()) / (kernel.max() - kernel.min() + 1e-8)
ax.imshow(kernel)
ax.axis('off')
plt.suptitle('VGG-16 第一层卷积核', fontsize=14)
plt.tight_layout()
plt.savefig('vgg_filters.png', dpi=150)
plt.show()四、本章小结
本章系统介绍了卷积神经网络:
卷积操作:通过局部连接和权值共享,CNN大幅减少参数量,同时利用图像的局部结构和平移不变性。
池化层:降低特征图分辨率,增加感受野,提供一定的平移不变性。
经典架构:从LeNet到ResNet,CNN架构不断加深,残差连接解决了深层网络的训练问题。
感受野:决定了每个输出能"看到"输入的多大区域,影响模型的感知范围。
特征层次:浅层学习边缘、纹理等低级特征,深层学习语义级别的高级特征。
CNN是计算机视觉的基石,理解其原理是进入视觉领域的必经之路。
五、思考与练习
思考题
1x1卷积的作用是什么?它在哪些架构中被使用?
深度可分离卷积如何减少计算量?MobileNet的原理是什么?
为什么ResNet能训练1000层而VGG只能训练19层?
空洞卷积(dilated convolution)的作用是什么?
注意力机制如何与CNN结合?SE-Net的原理是什么?
编程练习
从零实现CNN:用NumPy实现卷积、池化、反向传播。
感受野计算:实现自动计算任意CNN架构的感受野。
特征可视化:用梯度上升法可视化CNN各层学到的特征。
架构对比:在CIFAR-10上对比VGG、ResNet、DenseNet的性能。
模型压缩:用深度可分离卷积替换标准卷积,比较参数量和准确率。