前言

本篇是我训练营的第一次学习,主要目标是使用 PyTorch 实现 MNIST 手写数字识别。MNIST 是一个非常经典的图像分类数据集,任务是让模型识别图片中的手写数字属于 0-9 中的哪一类。

因为我是python和深度学习初学者,所以这篇笔记暂时不追求完全理解所有数学原理,而是按照老师说的先跑通代码,再逐步理解的思路进行学习。
本文主要记录从环境配置、MNIST 数据导入、DataLoader 批量加载、数据可视化,到构建简单 CNN 网络、训练模型、测试模型和结果可视化的完整流程。
感谢K同学啊老师的教学,以及ChatGPT和KIMI CODE。


一、准备工作

1. 配置环境

教程的版本是python3.8,因为我最近一直用3.12,所以又新创建了一个3.12环境(虽然各大AI都建议我用3.11,但我准备出问题再降版本吧)

conda create -n torchstudy python=3.12

我的显卡是AMD的,所以虽然有GPU,但不支持CUDA,我在网上看到有人新开发了ZLUDA,可以将amd显卡伪装之后就能用GPU加速,但毕竟是小白把教程跑通最重要,先学习知识,后面如果尝试成功会再进行记录。

想要直接在VS Code里用jupyter别忘记安装ipykernel
在这里插入图片描述
激活环境

conda activate torchstudy

安装ipykernel,之后就可以去VS Code里按运行代码啦~后面还会用到matplotlib,一起安装了

conda install -c conda-forge ipykernel matplotlib

安装torchtorchvision,我安装了只有cpu的,PyTorch相关库建议用 pip 装

pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
pip install torchinfo

安装之后运行下面代码检查,不报错即可

import torch
import torchvision
import matplotlib
import torchinfo

print("torch版本:", torch.__version__)
print("torchvision版本:", torchvision.__version__)
torch版本: 2.11.0+cpu
torchvision版本: 0.26.0+cpu

环境配置完成🎉(-∀-)ノ~🎉*


2. 关于MNIST数据集

http://yann.lecun.com/exdb/mnist/

本次教程用MNIST数据集,MNIST 是一个非常经典的手写数字识别数据集,数字范围是0-9,共10类;
总共有70000张图片,训练集 (Training Set):60,000 张,测试集 (Test Set):10,000 张;
图片分辨率是28 × 28,这意味着每张图片在模型眼里,其实是一个由28 × 28=784 个数字组成的向量;
MNIST 图片是灰度图,每个像素表示亮度值,原始范围通常是 0-255。经过 ToTensor() 转换后,像素值会被缩放到 0-1,更适合神经网络进行计算。

3. 数据下载与导入

函数原型
torchvision.datasets.MNIST(root, train=True, transform=None, target_transform=None, download=False)
参数说明:
● root (string) :数据地址
● train (string) :True-训练集,False-测试集
● download (bool,optional) : 如果为True,从互联网上下载数据集,并把数据集放在root目录下
● transform (callable, optional ):这里的参数选择一个你想要的数据转化函数,直接完成数据转化
● target_transform (callable,optional) :接受目标并对其进行转换的函数/转换

3.1 创建 训练集测试集

train_ds = torchvision.datasets.MNIST('data', 
                                      train=True, 
                                      transform=torchvision.transforms.ToTensor(), # 将数据类型转化为Tensor
                                      download=True)

这一段的意思是:请从 torchvision.datasets 中读取 MNIST 数据集,把数据放到 data 文件夹里;我要的是训练集;读取图片时把图片转换成 Tensor;如果本地没有数据,就自动下载。
torchvision 是 PyTorch 中专门处理图像任务的工具包,datasets 是它里面的一个数据集仓库,MNIST收录其中;
'data'是数据文件存储路径,如果用绝对路径可以改写为root=r'D:\Datasets\mnist'
train=TrueMNIST 数据集本来就分成两部分,这里是调取训练集;
transform=torchvision.transforms.ToTensor()表示:读取图片时,把图片转换成 Tensor 张量格式。原始图片通常不是 PyTorch 模型可以直接训练的格式。模型需要的是数字张量,所以要把图片转换成 Tensor 张量格式。PyTorch 里,一张 MNIST 图片经过 ToTensor() 转换后,形状通常是:[1, 28, 28],其中: 1 是通道数,MNIST 是灰度图,所以只有 1 个通道; 28, 28 分别是图片高度和图片宽度。同时,ToTensor() 还会把像素值从0-255转换成0-1,这样更适合神经网络训练;
download=True如果本地没有 MNIST 数据集,就自动从网上下载。第一次运行时,它会下载数据,如果下载报错很可能由于无法连接下载数据导致的,可以手动下载数据集放到代码文件同一个目录下,直接从本地加载数据集。

test_ds  = torchvision.datasets.MNIST('data', 
                                      train=False, 
                                      transform=torchvision.transforms.ToTensor(), # 将数据类型转化为Tensor
                                      download=True)

训练集创建相同,唯一的区别就是train=False,这里读取测试集。


3.2 创建训练数据加载器DataLoader

函数原型
torch.utils.data.DataLoader(dataset, batch_size=1, shuffle=None, sampler=None, batch_sampler=None, num_workers=0, collate_fn=None, pin_memory=False, drop_last=False, timeout=0, worker_init_fn=None, multiprocessing_context=None, generator=None, *, prefetch_factor=2, persistent_workers=False, pin_memory_device=‘’)
参数说明:
● dataset (string) :加载的数据集
● batch_size (int,optional) :每批加载的样本大小(默认值:1)
● shuffle (bool,optional) : 如果为True,每个epoch重新排列数据。
● sampler (Sampler or iterable, optional) : 定义从数据集中抽取样本的策略。 可以是任何实现了 len 的 Iterable。 如果指定,则不得指定 shuffle 。
● batch_sampler (Sampler or iterable, optional) : 类似于sampler,但一次返回一批索引。与 batch_size、shuffle、sampler 和 drop_last 互斥。
● num_workers (int,optional) : 用于数据加载的子进程数。 0 表示数据将在主进程中加载(默认值:0)。

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)

这一段的意思是:每次从数据集中取出 32 张图片,打包成一捆(一个 batch);在深度学习中,我们通常不会一次性把全部 60000 张训练图片都送进模型,而是分批送进去,MNIST 训练集有 60000 张图片,那么训练时会分成1875 个批次,也就是说,模型每次看 32 张图片,更新一次参数。等所有批次都训练完,就完成了一轮训练,也就是一个epoch
batch_size = 32 设置参数为 32 ;利用
torch.utils.data.DataLoader()可以理解为数据传送带,目的就是把原始的训练集和测试集train_ds test_ds转换成7分批传送的train_dltest_dl
shuffle=True表示每个 epoch 之前把图片顺序打乱。防止模型按顺序记住答案(比如先看到全是"0",再看到全是"1"),让它真正学会识别特征。

其余的没有进行设置,初学不需要这么复杂,为了深刻理解,用AI帮助了一下,以下进行记录。
sampler的作用是指定从数据集中抽取样本的规则,如果你想按照某种特殊顺序抽样,或者想让某些类别被抽到的概率更高,就可以设置 sampler,我们已经设置了随机,sampler 和 shuffle 通常不要同时设置。
batch_sampler是更高级的采样方式,它不是一个一个样本抽,而是直接规定每一批 batch 里包含哪些样本,通常不能和batch_sizeshufflesamplerdrop_last这些参数同时使用。
num_workers这个参数控制用几个子进程来加载数据。=0表示只用主进程加载数据。
collate_fn的作用是规定如何把多个样本拼成一个 batch,我们的代码默认会自动把它们拼成:32 张图片组成 imgs,32 个标签组成 labels,MNIST 每张图片大小都一样,所以默认拼接方式完全够用。只有当你的数据长短不一致时,比如文本句子长度不同、目标检测中每张图片物体数量不同,才经常需要自定义 collate_fn。
pin_memory这个参数主要和 GPU 加速有关,我安装的是CPU 版 PyTorch,不需要改成True;
drop_last这个参数表示最后一个 batch 如果不够 batch_size,要不要丢掉,在普通训练和测试中,我们通常不想丢数据,所以保持默认,当然我觉得可以在设置size的时候就调整好;
timeout参数表示加载一个 batch 时最多等待多久。=0表示不特别设置超时时间;
worker_init_fn这个参数和num_workers有关,如果你用了多个子进程加载数据,有时希望每个子进程初始化时做一些特殊设置,比如设置随机种子,就可以用它;
multiprocessing_context这个参数也是和多进程加载数据有关,只有在你明确需要控制多进程启动方式时才会设置;
generator这个参数通常用于控制随机性,比如你想让shuffle=True的打乱结果可以复现,就可以传入一个随机数生成器。
prefetch_factor预加载因子,每个 worker 提前加载几批数据。默认 2 表示提前准备 2 批。数据加载很慢时,可以增大到 4 或 8。但配合num_workers>0才有效。
persistent_workers持久化子进程,每个 epoch 结束后,子进程是否销毁,如果设置为 True,多个 worker 在一个 epoch 结束后不会马上关闭,可以减少下一轮重新启动 worker 的开销,通常用于大数据集训练。
pin_memory_device=''这个参数也是和内存固定、设备传输有关,用 CPU 版 PyTorch,不需要设置。

数据导入完成之后取一个批次查看数据格式,数据的shape为[batch_size, channel, height, weight],batch_size为自己设定,channelheightweight分别是图片的通道数,高度和宽度。

imgs, labels = next(iter(train_dl))
imgs.shape
torch.Size([32, 1, 28, 28])

next(iter(train_dl))''意思是从训练数据加载器中取出第一批数据。我们设置了batch_size = 32,DataLoader 每次取出的数据包含两部分(图片数据+标签数据),所以会有32 张图片 + 32 个标签。

torch.Size([32, 1, 28, 28])
↑ ↑ ↑ ↑
N C H W
│ │ │ └── 宽度:28 像素
│ │ └────── 高度:28 像素
│ └────────── 通道数:1(灰度图,黑白)
└─────────────── 批次大小:这一捆有 32 张图


4. 数据可视化

这一步主要是为了查看数据是否正确加载,以及理解 imgs 的图片格式。

import numpy as np

 # 指定图片大小,图像大小为20宽、5高的绘图(单位为英寸inch)
plt.figure(figsize=(20, 5)) 
for i, imgs in enumerate(imgs[:20]):
    # 维度缩减
    npimg = np.squeeze(imgs.numpy())
    # 将整个figure分成2行10列,绘制第i+1个子图。
    plt.subplot(2, 10, i+1)
    plt.imshow(npimg, cmap=plt.cm.binary)
    plt.axis('off')
    
#plt.show()  如果你使用的是Pycharm编译器,请加上这行代码

代码运行结果
这一段的意思是:从一个 batch 中取出前 20 张图片,将每张图片从 Tensor 转换成 NumPy 数组,并去掉多余维度,最后按照 2行 × 10列 的形式显示出来。
plt.figure(figsize=(20, 5))这句代码用于创建一个画布。表示画布宽度是 20 英寸,高度是 5 英寸。
for i, imgs in enumerate(imgs[:20])这一句表示从当前 batch 中取出前 20 张图片,并且逐张进行绘制。前面通过:

imgs, labels = next(iter(train_dl))

取出的 imgs 形状是[32, 1, 28, 28],也就是说,当前 batch 中一共有 32 张图片。因此imgs[:20]表示只取前 20 张图片用于显示;
imgs.numpy()表示把 PyTorch 的 Tensor 转换成 NumPy 数组;
np.squeeze()表示去掉多余的维度。经过 ToTensor() 转换后,形状是:[1, 28, 28]其中 1 表示灰度图的通道数,但是 plt.imshow() 显示灰度图时,一般需要的形状是[28, 28],所以np.squeeze(imgs.numpy())会把图片形状从[1, 28, 28]变成[28, 28],这样才能更方便地用 matplotlib 显示。
plt.subplot(2, 10, i+1)表示把整个画布分成2 行 × 10 列,一共可以放 20 张小图。其中i + 1,因为 Python 中 i 从 0 开始,但是 subplot() 中的位置编号从 1 开始,所以这里要写 i+1
plt.imshow(npimg, cmap=plt.cm.binary)这一句用于真正显示图片。npimg是已经处理好的单张图片数组,cmap=plt.cm.binary表示用黑白灰度方式显示图片。MNIST 本身就是灰度图,所以用黑白显示最合适。
至此全部准备工作完成啦~对小白来说真的不容易啊😭


二、构建简单的CNN网络

对于一般的 CNN 网络来说,通常可以分为两部分:

  1. 特征提取网络
  2. 分类网络
    特征提取网络主要负责从图片中提取特征,比如边缘、形状、局部结构等;分类网络则根据提取到的特征判断图片属于哪一类。
    对于 MNIST 手写数字识别来说,输入是一张 28 × 28 的灰度图,输出是 0-9 中的一个类别,所以最后的输出类别数是num_classes = 10

1. CNN 网络中的常用层

1.1 卷积层 nn.Conv2d

卷积层用于提取图片的局部特征。比如在手写数字识别中,卷积层可以帮助模型识别笔画、弯曲、边缘等特征。
例如:

self.conv1 = nn.Conv2d(1, 32, kernel_size=3)

这一句的意思是:

输入通道数:1
输出通道数:32
卷积核大小:3 × 3

因为 MNIST 是灰度图,所以输入通道数是 1,输出通道数是 32,表示这一层会提取出 32 个不同的特征图。


1.2 池化层 nn.MaxPool2d

池化层用于下采样,也就是缩小特征图的尺寸,同时保留主要特征。
例如:

self.pool1 = nn.MaxPool2d(2)

表示使用 2 × 2 的池化窗口,池化可以减少计算量,也能让模型更关注重要特征。


1.3 激活函数 ReLU

ReLU 是常用的激活函数,它可以增强神经网络的非线性表达能力,如果没有激活函数,神经网络的表达能力会受到很大限制。


1.4 全连接层 nn.Linear

全连接层主要用于分类,前面的卷积层和池化层提取了图片特征,后面的全连接层根据这些特征进行判断,最后输出属于 10 个数字类别的分数。
例如:

self.fc2 = nn.Linear(64, num_classes)

表示最后输出 10 个类别分数。


1.5 展平操作 torch.flatten()

卷积层输出的数据通常是四维的[batch_size, channel, height, width],但是全连接层需要的是二维数据[batch_size, features],所以在进入全连接层之前,需要将特征图展平成一维向量。


2. 定义 CNN 模型

import torch.nn.functional as F

num_classes = 10  # 图片的类别数

class Model(nn.Module):
    def __init__(self):
        super().__init__()
        
        # 特征提取网络
        self.conv1 = nn.Conv2d(1, 32, kernel_size=3)   # 第一层卷积,输入1通道,输出32通道,卷积核大小3×3
        self.pool1 = nn.MaxPool2d(2)                   # 第一层池化,池化核大小2×2
        self.conv2 = nn.Conv2d(32, 64, kernel_size=3)  # 第二层卷积,输入32通道,输出64通道,卷积核大小3×3
        self.pool2 = nn.MaxPool2d(2)
        
        # 分类网络
        self.fc1 = nn.Linear(1600, 64)
        self.fc2 = nn.Linear(64, num_classes)
        
    # 前向传播
    def forward(self, x):
        x = self.pool1(F.relu(self.conv1(x)))
        x = self.pool2(F.relu(self.conv2(x)))

        x = torch.flatten(x, start_dim=1)

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

这表示我们定义了一个模型类,名字叫 Model,它继承自nn.Module,在 PyTorch 中,只要自己定义神经网络模型,一般都要继承 nn.Module

第一层卷积与池化

self.conv1 = nn.Conv2d(1, 32, kernel_size=3)
self.pool1 = nn.MaxPool2d(2)

输入图片形状为[32, 1, 28, 28],经过第一层卷积,通道数从 1 变成 32,因为卷积核大小是 3 × 3,默认没有 padding,所以图片边缘会缩小,尺寸从28 × 28变成26 × 26,经过 2 × 2 最大池化后,尺寸变成13 × 13,所以第一轮之后,特征图大致变成[32, 32, 13, 13]

第二层卷积与池化

self.conv2 = nn.Conv2d(32, 64, kernel_size=3)
self.pool2 = nn.MaxPool2d(2)

第二层卷积输入通道数是 32,输出通道数是 64。尺寸变化大致是:
13 × 13 → 卷积后 11 × 11 → 池化后 5 × 5
所以第二轮之后,特征图变成[32, 64, 5, 5],其中32:batch_size、64:特征图通道数
5 :高度、5 :宽度

4. 加载并打印模型结构

from torchinfo import summary
model = Model().to(device)
summary(model)
=================================================================
Layer (type:depth-idx)                   Param #
=================================================================
Model                                    --
├─Conv2d: 1-1                            320
├─MaxPool2d: 1-2                         --
├─Conv2d: 1-3                            18,496
├─MaxPool2d: 1-4                         --
├─Linear: 1-5                            102,464
├─Linear: 1-6                            650
=================================================================
Total params: 121,930
Trainable params: 121,930
Non-trainable params: 0
=================================================================

前面我们设置了

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

因为我安装的是 CPU 版 PyTorch,所以这里模型会被放到 CPU 上运行,这个模型总共有 121,930 个可训练参数。


三、训练模型

模型定义好之后,就可以开始训练了。

1. 设置超参数

loss_fn    = nn.CrossEntropyLoss() # 创建损失函数
learn_rate = 1e-2                  # 学习率
opt        = torch.optim.SGD(model.parameters(), lr=learn_rate)

CrossEntropyLoss损失函数用于衡量模型预测结果和真实标签之间的差距。对于 MNIST 这种多分类任务,常用nn.CrossEntropyLoss(),因为 MNIST 有 10 个类别,属于典型的多分类问题,损失值越小,说明模型预测越接近真实标签。
learn_rate学习率控制模型参数每次更新的步子大小,1e-2等于0.01,如果学习率太大,模型可能训练不稳定;如果学习率太小,模型学习速度会很慢。
SGD优化器的作用是根据损失函数计算出来的梯度,更新模型参数。这里使用的是SGD,也就是随机梯度下降。
model.parameters()表示把模型中所有需要训练的参数交给优化器。


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

size = len(dataloader.dataset)表示训练集的总样本数量,对于 MNIST 训练集来说size = 60000
num_batches = len(dataloader)表示一共有多少个 batch。

optimizer.zero_grad()
loss.backward()
optimizer.step()

这是 PyTorch 训练神经网络最核心的三步。

第一步:清空梯度
PyTorch 中梯度默认会累加,所以每个 batch 开始训练前,需要先把上一轮的梯度清空。
第二步:反向传播
根据损失值自动计算每个参数的梯度。

第三步:更新参数
优化器根据梯度更新模型参数。


3. 编写测试函数

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

测试函数和训练函数很像,但是有两个区别:

  1. 测试时不更新模型参数;
  2. 测试时使用 torch.no_grad() 关闭梯度计算。

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')

输出结果如下

Epoch: 1, Train_acc:75.0%, Train_loss:0.812, Test_acc:91.3%,Test_loss:0.279
Epoch: 2, Train_acc:93.9%, Train_loss:0.203, Test_acc:95.6%,Test_loss:0.135
Epoch: 3, Train_acc:96.2%, Train_loss:0.127, Test_acc:97.1%,Test_loss:0.095
Epoch: 4, Train_acc:97.1%, Train_loss:0.097, Test_acc:97.5%,Test_loss:0.079
Epoch: 5, Train_acc:97.5%, Train_loss:0.082, Test_acc:98.1%,Test_loss:0.062
Done

一般来说,训练过程中,准确率应该逐渐升高,损失值应该逐渐降低,如果出现这种趋势,就说明模型正在学习。


四、结果可视化

训练结束后,可以把训练准确率、测试准确率、训练损失、测试损失画出来,这样更直观地观察模型训练效果。

import matplotlib.pyplot as plt
#隐藏警告
import warnings
warnings.filterwarnings("ignore")               #忽略警告信息
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()

这是我的结果
在这里插入图片描述
在这里插入图片描述

总结

这篇笔记对我来说主要是完成了从“数据导入 → 模型构建 → 模型训练 → 结果可视化”的完整流程。但学习并不完全,下一周需要再一次深化学习,后边的内容不太能看懂,只是运行了代码,后期学习后会再优化笔记。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐