Skip to content

章节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 x

3.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 x

3.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 微调策略

  1. 特征提取:冻结所有卷积层,仅训练新分类头
  2. 全模型微调:以预训练权重为初始值,所有层参与训练
  3. 渐进解冻:自顶向下逐步解冻层
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-1032×32 小图像分类基准数据集

练习

  1. Conv 计算:输入 32×32×3,使用 10 个 5×5 卷积核,stride=1, padding=2,求输出尺寸。
  2. 实现 LeNet:在 MNIST 上实现 LeNet-5,达到 99%+ 准确率。
  3. 残差网络:将练习 2 的 LeNet 加上残差连接,观察训练曲线变化。
  4. 迁移学习:使用预训练的 ResNet-18 在 CIFAR-10 上微调,对比从头训练的效果。
  5. 特征可视化:提取 CNN 中间层特征图并可视化,理解卷积核学到了什么。

Python 学习资料