【保姆级教程】PyTorch实战CIFAR-10:从零构建CNN,手撕卷积/池化计算!——深度学习入门(2)


1. 引言

在上一篇博客中,我们一起探索了使用​​CNN识别MNIST手写数字​​的完整流程,相信你已经对​​深度学习项目的基本构建​​有了初步的实践体验。

但是,深度学习不仅仅是“​​调包​​”和“​​跑通代码​​”。真正掌握CNN的精髓,关键在于理解其核心组件—— ​​卷积层(Convolution)​​ 和 ​​池化层(Pooling)​​ ——是如何对图像数据进行​​特征提取​​和​​空间降维​​的。很多教程会直接调用nn.Conv2d和nn.MaxPool2d这样的API,把计算过程当作“​​黑盒子​​”,这往往阻碍了我们灵活设计网络和调试问题。

​​本篇博客,我们将深入CNN的“心脏地带”!​​ 🎯 不同于浅尝辄止,我们的核心目标是:​​手把手带你推导卷积层与池化层的具体计算过程,并将其应用到彩色图像识别任务(CIFAR-10数据集)的实战中!

2. 环境准备

import torch
import torch.nn as nn
import matplotlib.pyplot as plt # 图像可视化
import torchvision  # 用于加载CIFAR-10
import numpy as np  # 数据处理
# 设置硬件设备,如果有GPU则使用,没有则使用cpu
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
device  
 # 如果输出结果是“device(type='cuda')”,则说明GPU版本的pytorch安装成功

3. 数据加载与预处理

  • 加载CIFAR-10
    • 使用 torchvision.datasets.CIFAR10 加载训练集和测试集。
    • 解释参数:root (数据存放路径), train (区分训练/测试), download (是否下载), transform (数据处理)。
  • 数据预处理
    • ToTensor():将PIL/Numpy图像转为PyTorch Tensor (C x H x W), 并归一化到 [0, 1]。
  • 创建DataLoader:
    • 使用 torch.utils.data.DataLoader 封装数据集。
    • 解释参数:batch_size (批次大小), shuffle (训练集需打乱), num_workers (数据加载进程数)。
  • ​​可视化:​​ 展示几个批次中的图片样本(用matplotlib),让读者对数据有直观感受。
# 加载CIFAR-10
train_ds = torchvision.datasets.CIFAR10('data', 
                                      train=True, 
                                      transform=torchvision.transforms.ToTensor(), # 将数据类型转化为Tensor
                                      download=True)
test_ds  = torchvision.datasets.CIFAR10('data', 
                                      train=False, 
                                      transform=torchvision.transforms.ToTensor(), # 将数据类型转化为Tensor
                                      download=True)
# 创建DataLoader
batch_size = 32
train_dl = torch.utils.data.DataLoader(train_ds, 
                                       batch_size=batch_size, 
                                       shuffle=True)
test_dl  = torch.utils.data.DataLoader(test_ds, 
                                       batch_size=batch_size)
# 可视化
import numpy as np
# 指定图片大小,图像大小为20宽、5高的绘图(单位为英寸inch)
plt.figure(figsize=(20, 5)) 
for i, imgs in enumerate(imgs[:20]):
    # 进行轴变换
    npimg = imgs.numpy().transpose((1, 2, 0))
    # 将整个figure分成2行10列,绘制第i+1个子图。
    plt.subplot(2, 10, i+1)
    plt.imshow(npimg, cmap=plt.cm.binary)
    plt.axis('off')
    
#plt.show()  如果你使用的是Pycharm编译器,请加上这行代码                                      
  • 可视化结果如下:
    • 这里你的结果可能会和我不一样,这是因为DataLoader会把数据打乱,每次运行时DataLoader都不相同。

输出结果

4. 构建我们的CNN网络 (torch.nn.Module)

CNN实现思路

针对CIFAR-10(32x32像素的小尺寸彩色图像),一个有效的策略是采用多次堆叠[卷积+激活+池化]的模块化结构,最后连接全连接层进行分类;这样的设计是从LeNet-5中借鉴而来的,感兴趣的同学可以去了解一下LeNet-5并复现出来。下面列举了CNN中的各个部分的作用和用途。

模块作用关键设计点
卷积层(Conv)提取图像特征(边缘、纹理、物体部分)小卷积核(3x3)、通道数逐步增加
激活函数(ReLU)引入非线性,增强表达能力常用选择,计算高效
池化层(Pool)降维,提取显著特征,减少计算量最大池化(MaxPool)、2x2核/步长2
展平层(Flatten)过渡多维特征为分类网络输入将(C, H, W)展平为一维向量
全连接层(FC)组合高级特征进行最终分类输入尺寸需精确计算

严谨的说,这里的“展平层”并不能被称作一个层,它只是对特征图的一个处理(把所有像素拉平)。
卷积层的输入是图像时,一次扫描会扫描所有通道的值再加和成一张特征图。
当卷积层的输入是上层的特征图时,特征图会被当做“通道”对待,一次扫描会扫描所有输入的特征
图,加和成新的feature map。无论在哪一层,生成的feature map的数量都等于这一层的扫描次数,也就是等于out_channels的值。下一层卷积的in_channels就等于上一层卷积out_channels。
我们的具体网络流程如下:
输入图像(3, 32, 32)
→ [Conv3x3(64) → ReLU → MaxPool2x2]
→ [Conv3x3(64) → ReLU → MaxPool2x2]
→ [Conv3x3(128) → ReLU → MaxPool2x2]
→ Flatten
→ FC(256) → ReLU
→ FC(10)

4.2 CNN代码实现

import torch.nn.functional as F

num_classes = 10  # 图片的类别数

class Model(nn.Module):
     def __init__(self):
        super().__init__()
         # 特征提取网络
        self.conv1 = nn.Conv2d(3, 64, kernel_size=3)   
        self.pool1 = nn.MaxPool2d(kernel_size=2)       # 设置池化层,池化核大小为2*2
        self.conv2 = nn.Conv2d(64, 64, kernel_size=3)  # 第二层卷积,卷积核大小为3*3   
        self.pool2 = nn.MaxPool2d(kernel_size=2) 
        self.conv3 = nn.Conv2d(64, 128, kernel_size=3) # 第二层卷积,卷积核大小为3*3   
        self.pool3 = nn.MaxPool2d(kernel_size=2) 
                                      
        # 分类网络
        self.fc1 = nn.Linear(512, 256)          
        self.fc2 = nn.Linear(256, num_classes)
     # 前向传播
     def forward(self, x):
        x = self.pool1(F.relu(self.conv1(x)))     
        x = self.pool2(F.relu(self.conv2(x)))
        x = self.pool3(F.relu(self.conv3(x)))
        
        x = torch.flatten(x, start_dim=1)

        x = F.relu(self.fc1(x))
        x = self.fc2(x)
       
        return x

关键代码解释:

  • nn.Conv2d参数:
    • in_channels:输入数据的通道数(RGB为3)
    • out_channels:卷积产生的特征图数量
    • kernel_size:卷积核尺寸(这里是3*3)
    • stride=1,padding=0:默认值(无填充)
  • nn.MaxPool2d参数:
    • kernel_size=2:池化窗口尺寸(2*2)
    • stride=2:步长默认等于核尺寸(特征图尺寸减半)
  • nn.Linear关键点:
    • in_features:必须等于Flatten后的向量长度(需要手动计算推导)
  • forward流程:
    • 清晰的三阶段:特征提取 → 展平 → 分类
    • F.relu:ReLU激活函数增强非线性
    • torch.flatten(x, 1):从维度1开始展平(保留batch维度)

接下来我们可以通过torchinfo查看网络结果

from torchinfo import summary
# 将模型转移到GPU中(我们模型运行均在GPU中进行)
model = Model().to(device)
summary(model,[1, 3, 32, 32])

输出结果如下

==========================================================================================
Layer (type:depth-idx)                   Output Shape              Param #
==========================================================================================
Model                                    [1, 10]                   --
├─Conv2d: 1-1                            [1, 64, 30, 30]           1,792
├─MaxPool2d: 1-2                         [1, 64, 15, 15]           --
├─Conv2d: 1-3                            [1, 64, 13, 13]           36,928
├─MaxPool2d: 1-4                         [1, 64, 6, 6]             --
├─Conv2d: 1-5                            [1, 128, 4, 4]            73,856
├─MaxPool2d: 1-6                         [1, 128, 2, 2]            --
├─Linear: 1-7                            [1, 256]                  131,328
├─Linear: 1-8                            [1, 10]                   2,570
==========================================================================================
Total params: 246,474
Trainable params: 246,474
Non-trainable params: 0
Total mult-adds (M): 9.17
==========================================================================================
Input size (MB): 0.01
Forward/backward pass size (MB): 0.57
Params size (MB): 0.99
Estimated Total Size (MB): 1.56
==========================================================================================

4.3 手动推导核心层计算过程 (重点!)

​​为什么要手动推导?​​

理解每层输入/输出尺寸变化对于网络设计、调试(特别是全连接层尺寸错误)至关重要。我们以​​单张图像输入[1, 3, 32,32]​​为例逐步推导。

计算公式

🔍 逐步推导流程(详细版)

1. 输入层 (Input)
  • 输入尺寸:[1, 3, 32, 32] (Batch, Channels, Height, Width)
  • 原始图像尺寸:32×32像素,3个颜色通道(RGB)
2. Conv1 (3→64, kernel=3, stride=1, padding=0)

H_out = (H_in + 2×padding - kernel_size) / stride + 1
= (32 + 2×0 - 3) / 1 + 1
= (29) / 1 + 1
= 30

  • 宽度计算同理:W_out = 30
  • 输出尺寸:[1, 64, 30, 30]
  • 解释:卷积核在图像上滑动,每次移动1像素,产生30×30的特征图
3. Pool1 (MaxPool, kernel=2, stride=2)

H_out = (H_in - kernel_size) / stride + 1
= (30 - 2) / 2 + 1
= 28 / 2 + 1
= 14 + 1
= 15

  • 输出尺寸:[1, 64, 15, 15]
  • 解释:2×2窗口取最大值,步长为2,空间尺寸减半
4. Conv2 (64→64, kernel=3, stride=1, padding=0)

H_out = (15 + 2×0 - 3)/1 + 1
= (12)/1 + 1
= 13

  • 输出尺寸:[1, 64, 13, 13]
  • 说明:保持64通道数,提取更复杂的特征
5. Pool2 (MaxPool, kernel=2, stride=2)

H_out = (13 - 2)/2 + 1
= 11/2 + 1
= 5.5 + 1
→ 取整 = 6(PyTorch自动向下取整)

  • 输出尺寸:[1, 64, 6, 6]
  • 重要:尺寸非整除时执行floor操作
6. Conv3 (64→128, kernel=3, stride=1, padding=0)

H_out = (6 + 2×0 - 3)/1 + 1
= (3)/1 + 1
= 4

  • 输出尺寸:[1, 128, 4, 4]
  • 特征:通道数翻倍(128),提升模型表达能力
7. Pool3 (MaxPool, kernel=2, stride=2)

H_out = (4 - 2)/2 + 1
= 2/2 + 1
= 1 + 1
= 2

  • 输出尺寸:[1, 128, 2, 2]
  • 最终特征图:空间信息高度压缩,保留最显著特征
8. Flatten

特征图体积 = 通道 × 高度 × 宽度 = 128 × 2 × 2 = 512

  • 输出尺寸:[1, 512]
  • 连接卷积网络和分类网络的关键操作
9. FC1 (全连接层1: 512 → 256)

矩阵乘法: [1,512] × [512,256] = [1,256]

  • 输出尺寸:[1, 256]
  • 引入非线性特征组合能力
10. FC2 (输出层: 256 → 10)

矩阵乘法: [1,256] × [256,10] = [1,10]

  • 输出尺寸:[1, 10]
  • 生成10个类别的预测分数(Logits)
  • 强调:为什么关注尺寸计算?
    • 连接层: 池化层之后的特征图尺寸决定了 Flatten 层的 in_features,进而决定了第一个全连接层 nn.Linear 的 in_features。手动推导尺寸的目的是为了正确配置后续的全连接层!(如本例中,第二个池化层后输出 [1,
      64, 8, 8], Flatten 后 in_features=64 * 8 * 8=4096, 所以 nn.Linear(4096,
      128))。这体现了网络构建的连贯性和理解的必要性。

5. 模型训练与验证

5.1 损失函数(CrossEntropyLoss)与优化器(SGD)选择

loss_fn    = nn.CrossEntropyLoss() # 创建损失函数
learn_rate = 1e-2 # 学习率
opt        = torch.optim.SGD(model.parameters(),lr=learn_rate)  # 这里使用SGD随机梯度下降,MNIST数据简单,SGD足以胜任

5.2 训练函数与测试函数的实现

训练函数

# 训练循环
def train(dataloader, model, loss_fn, optimizer):
    size = len(dataloader.dataset)  # 训练集的大小,一共60000张图片
    num_batches = len(dataloader)   # 批次数目,1875(60000/32)

    train_loss, train_acc = 0, 0  # 初始化训练损失和正确率
    
    for X, y in dataloader:  # 获取图片及其标签
        X, y = X.to(device), y.to(device)
        
        # 计算预测误差
        pred = model(X)          # 网络输出
        loss = loss_fn(pred, y)  # 计算网络输出和真实值之间的差距,targets为真实值,计算二者差值即为损失
        
        # 反向传播三部曲
        optimizer.zero_grad()  # grad属性归零     (梯度归零)
        loss.backward()        # 反向传播         (梯度计算)
        optimizer.step()       # 每一步自动更新    (梯度更新)
         
        # 记录acc与loss
        train_acc  += (pred.argmax(1) == y).type(torch.float).sum().item()
        train_loss += loss.item()
            
    train_acc  /= size
    train_loss /= num_batches

    return train_acc, train_loss

测试函数

def test (dataloader, model, loss_fn):
    size        = len(dataloader.dataset)  # 测试集的大小,一共10000张图片
    num_batches = len(dataloader)          # 批次数目,313(10000/32=312.5,向上取整)
    test_loss, test_acc = 0, 0
    
    # 当不进行训练时,停止梯度更新,节省计算内存消耗
    with torch.no_grad():
        for imgs, target in dataloader:
            imgs, target = imgs.to(device), target.to(device)
            
            # 计算loss
            target_pred = model(imgs)
            loss        = loss_fn(target_pred, target)
            
            test_loss += loss.item()
            test_acc  += (target_pred.argmax(1) == target).type(torch.float).sum().item()

    test_acc  /= size
    test_loss /= num_batches

    return test_acc, test_loss

5.4 模型训练与评估

epochs     = 5
train_loss = []
train_acc  = []
test_loss  = []
test_acc   = []

for epoch in range(epochs):
    model.train()
    epoch_train_acc, epoch_train_loss = train(train_dl, model, loss_fn, opt)
    
    model.eval()
    epoch_test_acc, epoch_test_loss = test(test_dl, model, loss_fn)
    
    train_acc.append(epoch_train_acc)
    train_loss.append(epoch_train_loss)
    test_acc.append(epoch_test_acc)
    test_loss.append(epoch_test_loss)
    
    template = ('Epoch:{:2d}, Train_acc:{:.1f}%, Train_loss:{:.3f}, Test_acc:{:.1f}%,Test_loss:{:.3f}')
    print(template.format(epoch+1, epoch_train_acc*100, epoch_train_loss, epoch_test_acc*100, epoch_test_loss))
print('Done')

模型训练与预测结果:
模型训练与预测结果:

6. 运行结果与分析

import matplotlib.pyplot as plt
plt.rcParams['font.sans-serif']    = ['SimHei'] # 用来正常显示中文标签
plt.rcParams['axes.unicode_minus'] = False      # 用来正常显示负号
plt.rcParams['figure.dpi']         = 100        #分辨率

from datetime import datetime
current_time = datetime.now() # 获取当前时间

epochs_range = range(epochs)

plt.figure(figsize=(12, 3))
plt.subplot(1, 2, 1)

plt.plot(epochs_range, train_acc, label='Training Accuracy')
plt.plot(epochs_range, test_acc, label='Test Accuracy')
plt.legend(loc='lower right')
plt.title('Training and Validation Accuracy')
plt.xlabel(current_time) # 打卡请带上时间戳,否则代码截图无效

plt.subplot(1, 2, 2)
plt.plot(epochs_range, train_loss, label='Training Loss')
plt.plot(epochs_range, test_loss, label='Test Loss')
plt.legend(loc='upper right')
plt.title('Training and Validation Loss')
plt.show()

结果分析

7. 🎯总结与回顾

维度核心收获
目标达成完成CIFAR-10全流程实战(数据→模型→训练→评估)
核心突破✅ 手推卷积/池化计算逻辑
✅ 解析CNN架构设计原理
✅ 攻克全连接层尺寸难题
能力进阶获得白盒化建模能力(透明理解+自主设计+深度调试)
终极价值掌握深度学习黄金路径:理解原理→动手实践→迭代优化
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值