搭建卷积神经网络

卷积神经网络(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 的核心组件:拆解代码中的网络结构

python 复制代码
# 导入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. 空间信息保留:池化层在降维的同时保留关键空间特征,而全连接网络会丢失空间关系。
相关推荐
霍格沃兹软件测试开发6 小时前
Dify平台:Agent开发初学者指南
大数据·人工智能·深度学习
深度学习入门10 小时前
如何使用PyTorch搭建一个基础的神经网络并进行训练?
人工智能·pytorch·python·深度学习·神经网络·ai
AI浩10 小时前
Transformer架构三大核心:位置编码(PE)、前馈网络(FFN)和多头注意力(MHA)。
网络·深度学习·transformer
LifeEnjoyer10 小时前
贝叶斯分类(Bayes Classify)
人工智能·机器学习·分类
sjr200110 小时前
了解迁移学习吗?大模型中是怎么运用迁移学习的?
人工智能·机器学习·迁移学习
luoganttcc11 小时前
小鹏自动驾驶的BEV占用网络有哪些优势?
人工智能·机器学习·自动驾驶
云烟成雨TD12 小时前
NumPy 2.x 完全指南【三十二】通用函数(ufunc)之数学运算函数
python·机器学习·numpy
listhi52012 小时前
三电平逆变器SVPWM控制(无解耦功能)与谐波分析
算法·机器学习·支持向量机
Learn Beyond Limits14 小时前
Iterative loop of ML development|机器学习的迭代发展
人工智能·深度学习·神经网络·学习·机器学习·ai·吴恩达