7深度学习

卷积网络

视觉理解

卷积池化感受野ResNet

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第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 从全连接到卷积

python
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 卷积的数学定义

python
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 卷积的关键特性

python
# 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}")  # 1792

2.2 池化层

python
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)

python
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)

python
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 残差连接的作用

python
# 残差连接解决梯度消失问题
# 前向: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 感受野

python
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图像分类

python
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学到的特征

python
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的性能。

模型压缩:用深度可分离卷积替换标准卷积,比较参数量和准确率。

7/10卷积网络
0%