机器学习基础(十):卷积神经网络CNN

机器学习基础(十):卷积神经网络CNN

一、引言

在前面的学习中,我们已经掌握了 PyTorch 的基础用法,能够构建简单的全连接神经网络。但当面对图像数据时,我们遇到了新的问题:

一张 224×224 的彩色图像有 150,528 个像素,如果用全连接层处理,参数量会爆炸!

全连接网络的困境:

  • 参数过多:导致过拟合风险增加
  • 忽略空间结构:将图像展平为一维向量,丢失了像素间的空间关系
  • 计算效率低:大量冗余计算

卷积神经网络(Convolutional Neural Network, CNN) 正是为解决这些问题而生。它通过卷积操作提取图像特征,既减少了参数量,又保留了空间信息,是计算机视觉领域的基石。


二、什么是卷积?

1. 卷积的直观理解

卷积是一种特殊的线性运算,它使用一个卷积核(Kernel/Filter)在输入数据上滑动,进行局部加权求和。

类比:用放大镜观察图片

  • 卷积核就像放大镜,一次只看图像的一小块区域
  • 滑动扫描整幅图像,提取不同位置的特征

2. 卷积的数学定义

对于二维图像,卷积运算定义为:

\[(I * K)(i, j) = \sum_{m}\sum_{n} I(i+m, j+n) \cdot K(m, n) \]

其中:

  • \(I\) 是输入图像
  • \(K\) 是卷积核(通常大小为 3×3 或 5×5)
  • \((i, j)\) 是输出位置

3. 卷积操作示例

假设有一个 5×5 的输入图像和一个 3×3 的卷积核:

输入图像:          卷积核:
1 1 1 0 0          1 0 1
0 1 1 1 0          0 1 0
0 0 1 1 1          1 0 1
0 0 1 1 0
0 1 1 0 0

卷积过程(步长=1,无填充):

  1. 将卷积核放在图像左上角
  2. 对应元素相乘后求和
  3. 向右滑动,重复计算
  4. 到达边界后向下滑动一行

输出特征图大小计算:

\[Output Size = \frac{Input Size - Kernel Size + 2 \times Padding}{Stride} + 1 \]


三、CNN 的核心组件

1. 卷积层(Convolutional Layer)

作用:提取图像的局部特征(如边缘、纹理、形状)

关键参数:

参数 说明 常用值
kernel_size 卷积核大小 3×3, 5×5, 7×7
stride 步长(滑动间隔) 1, 2
padding 边缘填充 0, 1, 'same'
channels 输出通道数(卷积核数量) 32, 64, 128, 256

PyTorch 实现:

import torch.nn as nn

# 输入通道=3(RGB),输出通道=64,卷积核=3×3
conv_layer = nn.Conv2d(
    in_channels=3, 
    out_channels=64, 
    kernel_size=3, 
    stride=1, 
    padding=1
)

2. 激活函数

卷积后通常接激活函数引入非线性:

# ReLU 是最常用的选择
relu = nn.ReLU()

# 卷积 + 激活
output = relu(conv_layer(input))

3. 池化层(Pooling Layer)

作用:降低特征图尺寸,减少计算量,增强平移不变性

类型:

类型 操作 特点
Max Pooling 取窗口内最大值 保留最强特征,最常用
Average Pooling 取窗口内平均值 保留背景信息

PyTorch 实现:

# 2×2 最大池化,步长=2
max_pool = nn.MaxPool2d(kernel_size=2, stride=2)

# 2×2 平均池化
avg_pool = nn.AvgPool2d(kernel_size=2, stride=2)

池化效果:特征图尺寸减半(2×2 池化 + 步长2)

4. 批归一化(Batch Normalization)

作用:加速训练收敛,提高模型稳定性,减少对初始化的敏感

# 对 64 通道的特征图进行批归一化
bn = nn.BatchNorm2d(num_features=64)

5. 全连接层(Fully Connected Layer)

作用:将卷积层提取的特征映射到最终的分类结果

通常位于网络末端,前面需要将特征图展平:

# 假设经过卷积和池化后,特征图为 [batch, 512, 7, 7]
flatten = nn.Flatten()  # 展平为 [batch, 512*7*7]
fc = nn.Linear(512*7*7, num_classes)  # 映射到类别数

四、经典 CNN 架构

1. LeNet-5(1998)

开创者:Yann LeCun

结构:卷积 → 池化 → 卷积 → 池化 → 全连接

特点

  • 首个成功的 CNN 架构
  • 用于手写数字识别(MNIST)
  • 奠定了 CNN 的基本范式

2. AlexNet(2012)

突破:ImageNet 竞赛冠军,开启深度学习时代

创新点

  • 使用 ReLU 激活函数
  • 引入 Dropout 防止过拟合
  • 使用 GPU 加速训练

结构:5 层卷积 + 3 层全连接

3. VGGNet(2014)

核心思想:使用更小的卷积核(3×3)增加网络深度

特点

  • VGG-16:13 层卷积 + 3 层全连接
  • VGG-19:16 层卷积 + 3 层全连接
  • 结构规整,易于理解和实现

4. ResNet(2015)

革命性创新:残差连接(Residual Connection)

解决的问题:深层网络训练困难(梯度消失)

核心公式

\[y = F(x) + x \]

特点

  • ResNet-18/34/50/101/152
  • 可以训练超过 100 层的网络
  • 当前最广泛使用的骨干网络

五、CNN 的训练技巧

1. 数据增强(Data Augmentation)

通过对训练图像进行随机变换,增加数据多样性:

from torchvision import transforms

# 定义数据增强
train_transform = transforms.Compose([
    transforms.RandomResizedCrop(224),      # 随机裁剪
    transforms.RandomHorizontalFlip(),       # 随机水平翻转
    transforms.RandomRotation(10),           # 随机旋转
    transforms.ColorJitter(brightness=0.2,   # 颜色抖动
                          contrast=0.2),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406],
                        std=[0.229, 0.224, 0.225])
])

2. 学习率调度(Learning Rate Scheduling)

学习率调度是训练深度神经网络的关键技巧。为什么需要调整学习率?

直观理解:训练初期需要"大步流星"快速接近最优解,后期需要"小心翼翼"精细调整。

固定学习率的问题:

  • 学习率太大 → 震荡不收敛,甚至发散
  • 学习率太小 → 收敛极慢,陷入局部最优

常用学习率调度策略

(1)StepLR - 阶梯衰减

每隔固定轮数,学习率乘以衰减系数:

# 每 7 个 epoch,学习率乘以 0.1
scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=7, gamma=0.1)

# 训练循环中
for epoch in range(num_epochs):
    train(...)
    scheduler.step()  # 更新学习率
    print(f'当前学习率: {optimizer.param_groups[0]["lr"]}')

效果示例:初始 lr=0.1

  • Epoch 1-7: lr = 0.1
  • Epoch 8-14: lr = 0.01
  • Epoch 15-21: lr = 0.001

(2)MultiStepLR - 多阶段衰减

在指定的里程碑 epoch 进行衰减:

# 在第 30、60、90 轮分别衰减
scheduler = optim.lr_scheduler.MultiStepLR(
    optimizer, milestones=[30, 60, 90], gamma=0.1
)

(3)ExponentialLR - 指数衰减

每轮都按指数衰减:

# 每轮学习率乘以 0.95
scheduler = optim.lr_scheduler.ExponentialLR(optimizer, gamma=0.95)

公式:\(lr_{epoch} = lr_{initial} \times \gamma^{epoch}\)


(4)CosineAnnealingLR - 余弦退火 ⭐推荐

学习率按余弦曲线变化,常用于现代深度学习:

# T_max: 半个余弦周期的长度
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=200)

公式:\(\eta_t = \eta_{min} + \frac{1}{2}(\eta_{max} - \eta_{min})(1 + \cos(\frac{T_{cur}}{T_{max}}\pi))\)

特点

  • 平滑下降,避免学习率骤变
  • 周期性重启,有助于跳出局部最优
  • ResNet、EfficientNet 等现代架构常用

(5)ReduceLROnPlateau - 自适应衰减

当验证指标停止改善时自动降低学习率:

scheduler = optim.lr_scheduler.ReduceLROnPlateau(
    optimizer, mode='max',    # 'max' 表示监控指标越大越好(如准确率)
    factor=0.1,               # 衰减系数
    patience=5,               # 容忍 5 轮不改善
    verbose=True
)

# 训练循环中
for epoch in range(num_epochs):
    train(...)
    val_acc = validate(...)
    scheduler.step(val_acc)  # 传入监控指标

这是最实用的策略之一,无需预设衰减时机,自动适应训练情况。


(6)OneCycleLR - 单周期策略 ⭐⭐强烈推荐

先线性升温到最大学习率,再余弦降温到最小学习率:

scheduler = optim.lr_scheduler.OneCycleLR(
    optimizer,
    max_lr=0.1,                    # 最大学习率
    total_steps=num_epochs * len(train_loader),  # 总步数
    pct_start=0.3,                 # 升温阶段占比
    anneal_strategy='cos'          # 降温策略
)

# 注意:每个 batch 都要 step()
for epoch in range(num_epochs):
    for batch in train_loader:
        train_batch(...)
        scheduler.step()

优势

  • 训练速度更快
  • 通常能达到更好的最终性能
  • Fast.ai 库的核心技巧

学习率调度选择建议

场景 推荐策略
快速实验 StepLR、MultiStepLR
追求最佳性能 OneCycleLR、CosineAnnealingLR
不想调参 ReduceLROnPlateau
训练不稳定 先用小学习率 + 线性升温

关键经验

  • 学习率通常是最重要的超参数
  • 好的学习率调度可以让模型提升 1-3% 的准确率
  • 建议配合 学习率预热(Warmup) 使用,前几个 epoch 从小学习率逐渐增大

3. 早停(Early Stopping)

当验证集性能不再提升时停止训练,防止过拟合:

best_acc = 0.0
patience = 5
counter = 0

for epoch in range(num_epochs):
    train(...)
    val_acc = validate(...)
    
    if val_acc > best_acc:
        best_acc = val_acc
        torch.save(model.state_dict(), 'best_model.pth')
        counter = 0
    else:
        counter += 1
        if counter >= patience:
            print(f'早停于 epoch {epoch}')
            break

六、代码实战:MNIST 手写数字识别

下面是一个完整的 MNIST 手写数字识别示例,使用简单的 CNN 架构:

import torch
import torch.nn as nn
import torch.optim as optim
import torchvision
import torchvision.transforms as transforms
import time

# 设置设备
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"使用设备: {device}")

# ==================== 1. 数据准备 ====================
# MNIST 数据集:手写数字 0-9,28x28 灰度图像
transform = transforms.Compose([
    transforms.ToTensor(),  # 转换为 Tensor,归一化到 [0, 1]
    transforms.Normalize((0.1307,), (0.3081,))  # MNIST 标准归一化参数
])

# 加载数据集
train_dataset = torchvision.datasets.MNIST(
    root='./data', train=True, download=True, transform=transform)
test_dataset = torchvision.datasets.MNIST(
    root='./data', train=False, download=True, transform=transform)

train_loader = torch.utils.data.DataLoader(
    train_dataset, batch_size=64, shuffle=True)
test_loader = torch.utils.data.DataLoader(
    test_dataset, batch_size=64, shuffle=False)

print(f"训练集大小: {len(train_dataset)}")
print(f"测试集大小: {len(test_dataset)}")

# ==================== 2. 定义 CNN 模型 ====================
class CNN(nn.Module):
    """简单的 CNN 用于 MNIST 手写数字识别"""
    
    def __init__(self):
        super(CNN, self).__init__()
        # 卷积层 1: 1@28x28 -> 32@28x28 -> 32@14x14 (池化后)
        self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1)
        self.bn1 = nn.BatchNorm2d(32)
        
        # 卷积层 2: 32@14x14 -> 64@14x14 -> 64@7x7 (池化后)
        self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
        self.bn2 = nn.BatchNorm2d(64)
        
        # 池化层
        self.pool = nn.MaxPool2d(2, 2)
        self.dropout = nn.Dropout(0.25)
        
        # 全连接层: 64@7x7 = 3136 -> 128 -> 10
        self.fc1 = nn.Linear(64 * 7 * 7, 128)
        self.fc2 = nn.Linear(128, 10)
        self.relu = nn.ReLU()
    
    def forward(self, x):
        # 卷积块 1
        x = self.relu(self.bn1(self.conv1(x)))  # [batch, 32, 28, 28]
        x = self.pool(x)                         # [batch, 32, 14, 14]
        
        # 卷积块 2
        x = self.relu(self.bn2(self.conv2(x)))  # [batch, 64, 14, 14]
        x = self.pool(x)                         # [batch, 64, 7, 7]
        
        # 展平
        x = x.view(x.size(0), -1)               # [batch, 3136]
        x = self.dropout(x)
        
        # 全连接
        x = self.relu(self.fc1(x))              # [batch, 128]
        x = self.fc2(x)                          # [batch, 10]
        return x


# ==================== 3. 训练 ====================
model = CNN().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)

# 统计信息
train_losses = []
train_accuracies = []
test_accuracies = []

# 训练函数
def train(epoch):
    model.train()
    running_loss = 0.0
    correct = 0
    total = 0
    
    for batch_idx, (inputs, targets) in enumerate(train_loader):
        inputs, targets = inputs.to(device), targets.to(device)
        
        optimizer.zero_grad()
        outputs = model(inputs)
        loss = criterion(outputs, targets)
        loss.backward()
        optimizer.step()
        
        running_loss += loss.item()
        _, predicted = outputs.max(1)
        total += targets.size(0)
        correct += predicted.eq(targets).sum().item()
    
    epoch_loss = running_loss / len(train_loader)
    epoch_acc = 100. * correct / total
    train_losses.append(epoch_loss)
    train_accuracies.append(epoch_acc)
    print(f'Epoch {epoch}: 训练损失: {epoch_loss:.4f}, 训练准确率: {epoch_acc:.2f}%')


# 测试函数
def test():
    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()
    
    acc = 100. * correct / total
    test_accuracies.append(acc)
    print(f'测试准确率: {acc:.2f}%')
    return acc


# 开始训练
print("\n开始训练...")
start_time = time.time()

num_epochs = 5
for epoch in range(1, num_epochs + 1):
    train(epoch)
    test()

end_time = time.time()
training_time = end_time - start_time

print(f'\n训练完成!总耗时: {training_time:.2f}秒')
print(f'最终测试准确率: {test_accuracies[-1]:.2f}%')

训练结果

在 NVIDIA GPU 上训练 5 个 epoch 的典型结果:

Epoch 训练损失 训练准确率 测试准确率
1 0.2156 93.45% 98.20%
2 0.0689 97.89% 98.85%
3 0.0512 98.42% 99.05%
4 0.0418 98.71% 99.15%
5 0.0356 98.89% 99.20%

关键结论:

  • ✅ 仅需 5 轮训练,测试准确率即可达到 99% 以上
  • ✅ 训练时间约 30-60 秒(GPU)或 2-3 分钟(CPU)
  • ✅ CNN 对 MNIST 这种简单图像分类任务非常高效
  • ✅ 模型参数量约 50 万,远小于全连接网络

七、CNN 的优缺点

优点

优点 说明
参数共享 卷积核在整个图像上共享,大幅减少参数量
局部连接 每个神经元只连接局部区域,提取局部特征
平移不变性 池化操作使网络对位置变化具有一定鲁棒性
层次化特征 浅层提取边缘纹理,深层提取语义特征
端到端学习 自动学习特征,无需手工设计

缺点

缺点 说明
需要大量数据 深层网络需要大量标注数据训练
计算资源需求高 训练需要 GPU 加速
可解释性较弱 深层特征难以直观理解
对旋转缩放敏感 需要数据增强来提高鲁棒性

八、应用场景

CNN 在计算机视觉领域无处不在:

  • 图像分类:识别图像中的物体类别(ImageNet)
  • 目标检测:定位并识别图像中的多个物体(YOLO、Faster R-CNN)
  • 语义分割:像素级分类,识别每个像素的类别(U-Net、DeepLab)
  • 人脸识别:FaceID、人脸验证系统
  • 医学影像:肿瘤检测、病变分割
  • 自动驾驶:车道检测、行人识别

九、总结

卷积神经网络是深度学习在计算机视觉领域取得突破的关键:

核心思想:通过卷积操作提取局部特征,通过池化降低维度,通过堆叠层数学习层次化表示。

关键点回顾:

要点 内容
卷积层 提取局部特征,参数共享
池化层 降维,增强平移不变性
激活函数 引入非线性(ReLU)
批归一化 加速训练,提高稳定性
残差连接 解决深层网络训练困难

从 LeNet 到 ResNet,CNN 架构不断演进,但其核心思想——局部感受野、权值共享、层次化特征提取——始终未变。掌握 CNN,你就掌握了计算机视觉的基石。


标签:机器学习, 深度学习, CNN, 卷积神经网络, 计算机视觉, 图像分类, PyTorch, MNIST

posted @ 2026-04-03 23:38  YZG5N  阅读(184)  评论(0)    收藏  举报