搭建卷积神经网络

发布于:2025-09-01 ⋅ 阅读:(19) ⋅ 点赞:(0)

卷积神经网络(CNN)讲解:从原理到 MNIST 实战

在深度学习领域,卷积神经网络(Convolutional Neural Network, CNN)是专门为图像任务设计的神经网络架构。它通过模拟人类视觉系统的 “局部感知” 特性,解决了传统全连接网络处理图像时 “参数爆炸” 和 “忽略空间信息” 的痛点。本文将结合之前的 MNIST 手写数字识别代码,从核心原理、组件拆解、数据流动到训练逻辑,全面讲解 CNN 的工作机制。

一、为什么需要 CNN?—— 图像任务的特殊性
以 MNIST 数据集为例,每张图像是28×28 的灰度图(1 个通道)。若用全连接网络,输入层需要 28×28=784 个神经元;若第一层隐藏层设为 1000 个神经元,仅这一层的参数就有 784×1000=78.4万 个。而 CNN 通过两个核心机制大幅减少参数,同时保留图像的空间特征:

  1. 局部感知:人类看图像时先关注局部(如边缘、纹理),再组合成整体。CNN 的卷积层仅用 “卷积核”(小窗口)提取局部特征,而非关注整个图像。
  2. 参数共享:一个卷积核在整个图像上滑动时,使用同一组参数(权重),避免为每个像素位置单独设置参数。

二、CNN 的核心组件:拆解代码中的网络结构

# 导入PyTorch核心库
import torch
# 从torchvision导入数据集模块,用于加载MNIST等标准数据集
from torchvision import datasets
# 从torch导入神经网络模块(nn)、优化器模块(optim)
from torch import nn, optim
# 导入数据加载器,用于批量加载数据
from torch.utils.data import DataLoader
# 导入ToTensor转换工具,用于将图像转换为PyTorch张量
from torchvision.transforms import ToTensor

# 加载MNIST手写数字数据集(训练集)
training_data = datasets.MNIST(
    root='data',          # 数据存储路径
    train=True,           # 加载训练集
    download=True,        # 如果本地没有数据则自动下载
    transform=ToTensor()  # 数据转换:将PIL图像转为张量,并归一化到[0,1]范围
)

# 加载MNIST手写数字数据集(测试集)
test_data = datasets.MNIST(
    root='data',          # 数据存储路径
    train=False,          # 加载测试集
    download=True,        # 如果本地没有数据则自动下载
    transform=ToTensor()  # 同样进行张量转换
)

# 打印数据集大小,确认数据加载成功
print(f"训练集大小: {len(training_data)}, 测试集大小: {len(test_data)}")

# 创建训练集数据加载器
train_dataloader = DataLoader(
    training_data, 
    batch_size=64,  # 每次迭代加载64个样本
    shuffle=True    # 训练时打乱数据顺序,增强模型泛化能力
)

# 创建测试集数据加载器
test_dataloader = DataLoader(
    test_data, 
    batch_size=64,  # 测试时同样使用64的批次大小
    shuffle=True    # 测试时打乱数据(非必须,这里仅为演示)
)

# 查看数据集中样本的形状,确认数据格式正确
for x, y in test_dataloader:
    # x: 输入图像张量,形状为[batch_size, 通道数, 高度, 宽度]
    # y: 标签张量,形状为[batch_size]
    print(f"输入形状: {x.shape}, 标签形状: {y.shape}")
    break  # 只查看第一个批次

# 自动判断并选择可用的计算设备,优先使用GPU加速
device = torch.device(
    'cuda' if torch.cuda.is_available() else  # 检查NVIDIA GPU
    'mps' if torch.backends.mps.is_available() else  # 检查Apple M系列芯片
    'cpu'  # 都不支持则使用CPU
)
print(f"使用设备: {device}")


# 定义卷积神经网络(CNN)模型,继承自nn.Module
class CNN(nn.Module):
    def __init__(self):
        super(CNN, self).__init__()  # 调用父类构造函数
        
        # 第一个卷积块:卷积层 + ReLU激活 + 最大池化
        self.conv1 = nn.Sequential( #Sequential创建一个容器,将多个层组合在一起
            nn.Conv2d(              #2d用于处理图像
                in_channels=1,      # 输入通道数:MNIST是灰度图,所以为1
                out_channels=32,    # 输出通道数:32个卷积核,提取32种特征
                kernel_size=5,      # 卷积核大小:5x5
                stride=1,           # 步长:1,每次移动1个像素
                padding=2           # 填充:2,保持卷积后特征图大小不变
            ),
            nn.ReLU(),             # 激活函数:将数据进行非线性映射
            nn.MaxPool2d(2)        # 最大池化:2x2窗口,将特征图尺寸缩小一半
        )

        # 第二个卷积块:两个卷积层 + ReLU激活 + 最大池化
        self.conv2 = nn.Sequential(
            # 第一个卷积:32->32通道
            nn.Conv2d(in_channels=32, out_channels=32, kernel_size=5, stride=1, padding=2),
            nn.ReLU(),
            # 第二个卷积:32->64通道
            nn.Conv2d(in_channels=32, out_channels=64, kernel_size=5, stride=1, padding=2),
            nn.ReLU(),
            nn.MaxPool2d(2)        # 再次池化,特征图尺寸再缩小一半
        )

        # 第三个卷积块:单个卷积层 + ReLU激活(无池化,保留特征图尺寸)
        self.conv3 = nn.Sequential(
            nn.Conv2d(in_channels=64, out_channels=128, kernel_size=5, stride=1, padding=2),
            nn.ReLU(),
        )

        # 全连接层:将卷积提取的特征映射到10个类别(0-9)
        self.out = nn.Linear(in_features=128 * 7 * 7, out_features=10)
        # 输入特征数计算:128通道,每个特征图7x7(原始28x28经两次池化后变为7x7)

    # 前向传播函数:定义数据在网络中的流动路径
    def forward(self, x):
        x = self.conv1(x)   # 经过第一个卷积块
        x = self.conv2(x)   # 经过第二个卷积块
        x = self.conv3(x)   # 经过第三个卷积块
        x = x.view(x.size(0), -1)  # 展平操作:将四维张量[batch, 128, 7, 7]转为二维[batch, 128*7*7]
        output = self.out(x)       # 经过全连接层得到最终输出
        return output


# 初始化模型并将其移动到之前选择的计算设备上
model = CNN().to(device)


# 训练函数:负责模型的训练过程
def train(dataloader, model, loss_fn, optimizer, epoch, device):
    model.train()  # 设置模型为训练模式(启用 dropout、批量归一化等训练特有的层)
    total_loss = 0.0  # 记录总损失
    batch_size_num = 1  # 批次计数器

    # 遍历数据加载器中的每个批次
    for x, y in dataloader:
        # 将输入和标签移动到计算设备
        x = x.to(device)
        y = y.to(device)
        
        # 前向传播:计算模型预测值
        pred = model(x)
        
        # 计算损失:预测值与真实标签的差距
        loss = loss_fn(pred, y)

        # 反向传播与参数优化
        optimizer.zero_grad()  # 清零梯度,避免梯度累积
        loss.backward()        # 反向传播计算梯度
        optimizer.step()       # 更新模型参数

        # 累加损失
        total_loss += loss.item()
        
        # 每100个批次打印一次损失,监控训练过程
        if batch_size_num % 100 == 0:
            print(f"Epoch {epoch}, Batch {batch_size_num}, Loss: {loss.item():.7f}")
        
        batch_size_num += 1

    # 计算并打印该轮的平均损失
    avg_loss = total_loss / len(dataloader)
    print(f"Epoch {epoch} 训练平均损失: {avg_loss:.7f}")
    return avg_loss  # 返回平均损失用于后续分析


# 测试函数:评估模型在测试集上的性能
def test(dataloader, model, loss_fn, epoch, device):
    size = len(dataloader.dataset)  # 测试集总样本数
    num_batches = len(dataloader)   # 测试集批次数
    model.eval()                    # 设置模型为评估模式(关闭 dropout 等)
    total_loss = 0.0                # 总测试损失
    correct = 0                     # 正确预测的样本数

    # 关闭梯度计算(测试时不需要更新参数,节省内存和计算资源)
    with torch.no_grad():
        # 遍历测试集中的每个批次
        for x, y in dataloader:
            x = x.to(device)
            y = y.to(device)
            pred = model(x)  # 前向传播获取预测值
            
            # 累加测试损失
            total_loss += loss_fn(pred, y).item()
            # 计算正确预测数:取预测概率最大的类别与真实标签比较
            correct += (pred.argmax(1) == y).type(torch.float).sum().item()

    # 计算平均损失和准确率
    avg_loss = total_loss / num_batches
    accuracy = correct / size
    print(f"Epoch {epoch} 测试平均损失: {avg_loss:.7f}, 准确率: {accuracy * 100:.2f}%\n")
    return avg_loss, accuracy  # 返回测试损失和准确率用于后续分析


# 定义损失函数:交叉熵损失,适用于分类任务
loss_fn = nn.CrossEntropyLoss()
# 定义优化器:Adam优化器,学习率为0.001
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

# 训练参数设置
epochs = 10  # 训练轮次
# 用于记录训练过程中的指标,便于后续可视化
train_losses = []      # 训练损失
test_losses = []       # 测试损失
test_accuracies = []   # 测试准确率

# 开始训练循环
for epoch in range(1, epochs + 1):
    print(f"\nEpoch {epoch}/{epochs}")
    print("-" * 50)  # 分隔线,美化输出

    # 训练模型并记录训练损失
    train_loss = train(train_dataloader, model, loss_fn, optimizer, epoch, device)
    train_losses.append(train_loss)

    # 测试模型并记录测试损失和准确率
    test_loss, test_acc = test(test_dataloader, model, loss_fn, epoch, device)
    test_losses.append(test_loss)
    test_accuracies.append(test_acc)

print("训练完成!")
# 打印模型结构,确认网络配置
print(model)

以上的代码定义了一个 3 个卷积块 + 1 个全连接层的 CNN 模型(class CNN(nn.Module)),我们逐一拆解每个核心组件的作用,以及代码中的具体实现。

1. 卷积层(Conv2d):提取图像局部特征

卷积层是 CNN 的 “眼睛”,负责从图像中提取低级特征(如边缘、线条)和高级特征(如轮廓、形状)。

代码中的卷积层示例(以conv1为例):

python

nn.Conv2d(
    in_channels=1,      # 输入通道数:MNIST是灰度图,仅1个通道(彩色图为3)
    out_channels=32,    # 输出通道数:32个卷积核,提取32种不同特征
    kernel_size=5,      # 卷积核大小:5×5的正方形窗口(局部感知范围)
    stride=1,           # 步长:卷积核每次滑动1个像素
    padding=2           # 填充:在图像边缘补2个像素,确保卷积后尺寸不变
)
关键计算:卷积后特征图的尺寸

卷积层输出的 “特征图” 尺寸是核心指标,公式为:
输出尺寸 = (输入尺寸 - 卷积核尺寸 + 2×padding) / stride + 1
conv1为例:
输入尺寸 = 28,卷积核 = 5,padding=2,stride=1
输出尺寸 = (28 - 5 + 2×2)/1 + 1 = 28 → 特征图仍为 28×28,保证空间信息不丢失。

2. 激活函数(ReLU):引入非线性特征

卷积层的输出是 “线性组合”(类似y=wx+b),无法捕捉图像中的复杂非线性关系(如曲线、不规则边缘)。激活函数的作用是给特征图加入非线性,让 CNN 能学习更复杂的特征。

代码中的实现:

python

nn.ReLU()  # 最常用的激活函数,公式:ReLU(x) = max(0, x)

ReLU 的优势:计算简单(避免梯度消失)、能快速收敛,是图像任务的首选激活函数。

3. 池化层(MaxPool2d):降维与特征浓缩

池化层(又称下采样层)的核心作用是减少特征图尺寸,从而降低计算量和过拟合风险,同时保留关键特征(如 “最大值” 对应最显著的局部特征)。

代码中的实现(以conv1后的池化为例):

python

nn.MaxPool2d(2)  # 2×2的最大池化窗口,步长默认等于窗口大小
关键计算:池化后尺寸

最大池化会将2×2的窗口压缩为 1 个像素(取窗口内最大值),因此尺寸直接减半:
conv1输出的 28×28 特征图,经过MaxPool2d(2)后,尺寸变为 14×14

4. 卷积块:层的 “组合拳”

代码中没有单独使用卷积层,而是将 “卷积层 + 激活函数 + 池化层”(或多卷积层 + 激活函数)组合成卷积块(如conv1conv2conv3),这是 CNN 的经典设计范式:

  • conv1:1 个卷积层 + ReLU + 最大池化 → 提取最基础的边缘特征
  • conv2:2 个卷积层 + ReLU + 最大池化 → 组合基础特征,提取轮廓特征
  • conv3:1 个卷积层 + ReLU → 进一步细化高级特征(无池化,避免过度降维)

5. 全连接层(Linear):从特征到分类

经过多轮卷积和池化后,特征图已经浓缩了图像的关键信息,但仍需通过全连接层将 “空间特征” 转化为 “类别概率”。

代码中的实现:

python

self.out = nn.Linear(in_features=128 * 7 * 7, out_features=10)

  • in_features=128×7×7:输入特征数。conv3输出的特征图是128通道×7×7尺寸(怎么来的?下文数据流动会讲),需要通过x.view(x.size(0), -1)展平为 1 维向量(batch_size × 128×7×7)。
  • out_features=10:输出特征数 = MNIST 的类别数(0-9),最终输出每个类别的 “logits 分数”(通过 Softmax 可转化为概率)。

三、CNN 的数据流动:跟着代码走一遍

理解 CNN 的关键是跟踪数据在网络中的尺寸变化。我们以代码中的forward函数为线索,从输入(28×28 的 MNIST 图像)到输出(10 个类别分数),完整梳理数据流动过程:

输入:MNIST 图像张量

输入数据x的形状为 [batch_size, 1, 28, 28](批量大小 × 通道数 × 高度 × 宽度),对应代码中test_dataloader打印的 “输入形状: torch.Size ([64, 1, 28, 28])”(batch_size=64)。

1. 经过conv1:基础特征提取

python

x = self.conv1(x)  # conv1 = 卷积层(1→32) + ReLU + MaxPool2d(2)

  • 卷积层后:[64, 32, 28, 28](通道从 1→32,尺寸保持 28×28)
  • ReLU 后:形状不变,仅值做非线性变换 → [64, 32, 28, 28]
  • 最大池化后:尺寸减半 → [64, 32, 14, 14]

2. 经过conv2:轮廓特征提取

python

x = self.conv2(x)  # conv2 = 卷积层(32→32) + ReLU + 卷积层(32→64) + ReLU + MaxPool2d(2)

  • 第一个卷积层(32→32):尺寸不变 → [64, 32, 14, 14]
  • ReLU 后:形状不变 → [64, 32, 14, 14]
  • 第二个卷积层(32→64):尺寸不变 → [64, 64, 14, 14]
  • ReLU 后:形状不变 → [64, 64, 14, 14]
  • 最大池化后:尺寸减半 → [64, 64, 7, 7]

3. 经过conv3:高级特征细化

python

x = self.conv3(x)  # conv3 = 卷积层(64→128) + ReLU

  • 卷积层(64→128):尺寸不变(padding=2) → [64, 128, 7, 7]
  • ReLU 后:形状不变 → [64, 128, 7, 7]

4. 展平(view):适配全连接层

python

x = x.view(x.size(0), -1)  # 将4维张量展平为2维

  • x.size(0):批量大小(64)
  • -1:自动计算剩余维度 → 128×7×7=6272
  • 展平后形状:[64, 6272]

5. 经过全连接层:输出类别分数

python

output = self.out(x)  # 全连接层(6272→10)

  • 输出形状:[64, 10] → 每个样本对应 10 个类别(0-9)的分数。

四、CNN 的训练与评估:代码中的核心逻辑

CNN 的训练流程与其他神经网络一致,但需注意 “训练模式” 和 “评估模式” 的区别,以及梯度计算的开关。我们结合代码中的traintest函数讲解:

1. 训练函数(train):更新模型参数

训练的核心是 “梯度下降”—— 通过反向传播计算参数梯度,再用优化器更新参数,最小化损失。

关键步骤:

python

model.train()  # 设为训练模式:启用Dropout、BatchNorm的训练逻辑
for x, y in dataloader:
    x, y = x.to(device), y.to(device)  # 数据移到GPU/CPU
    pred = model(x)  # 前向传播:计算预测值
    loss = loss_fn(pred, y)  # 计算损失(CrossEntropyLoss适配分类任务)
    
    # 反向传播与优化
    optimizer.zero_grad()  # 清零梯度(避免累积)
    loss.backward()        # 反向传播:计算各层梯度
    optimizer.step()       # 优化器更新参数(如Adam)

  • 损失函数:nn.CrossEntropyLoss() → 同时包含 “Softmax” 和 “交叉熵”,直接输入 logits 即可。
  • 优化器:torch.optim.Adam() → 自适应学习率,收敛快,适合 CNN 训练。

2. 评估函数(test):验证模型性能

评估时不需要更新参数,因此需关闭梯度计算,避免内存浪费;同时需切换到 “评估模式”。

关键步骤:

python

model.eval()  # 设为评估模式:关闭Dropout、固定BatchNorm参数
with torch.no_grad():  # 关闭梯度计算
    for x, y in dataloader:
        pred = model(x)
        total_loss += loss_fn(pred, y).item()  # 累加测试损失
        # 计算准确率:取预测概率最大的类别(pred.argmax(1))与真实标签比较
        correct += (pred.argmax(1) == y).type(torch.float).sum().item()

  • 准确率计算:correct / len(dataloader.dataset) → 正确预测数 / 总样本数。

3. 训练效果:预期结果

在 MNIST 数据集上,该 CNN 模型训练 10 轮后:

  • 训练损失会从初始的~2.3(随机猜测水平)下降到~0.0001
  • 测试准确率会达到 99% 以上 → 充分说明 CNN 对图像任务的有效性。

五、总结:CNN 为什么能搞定图像任务?

从原理到代码,我们可以总结出 CNN 的核心优势:

  1. 局部感知 + 参数共享:大幅减少参数数量,避免过拟合,降低计算成本。
  2. 特征层级提取:从低级特征(边缘)到高级特征(形状),逐步抽象,符合人类视觉认知。
  3. 空间信息保留:池化层在降维的同时保留关键空间特征,而全连接网络会丢失空间关系。

网站公告

今日签到

点亮在社区的每一天
去签到