入门级深度神经网络 with Pytorch(1) - 从MNIST开始

前言

10年前还在校园里的时候,如果问我什么是算法,我脑海中出现的必定是快速排序、二叉树搜索、图论、leetcode、算法题刷刷刷…这类的东西。没错它们确实是算法,但仅仅只是掌握了它们,在现在这个时代却是不能自称算法工程师的。

今年是2023年,作为一名开发者,你不可能没听说过深度神经网络、深度学习。而今如果想要自称是一名算法工程师,即使你可能对不同模型底层的原理一知半解,但你也必须得熟练使用至少一个深度学习的框架。

深度神经网络是门相当复杂的学问,也是门有些玄学的学问(炼丹了解一下)。我不打算一上来就甩一些很深奥的知识和理论,上几张被到处引用的图,再来一堆不带说明的pytorch代码来直接跟你讲什么是Transformer,什么是GAN(当然,之后也不打算讲的很深奥),作为一名实用主义者,我觉得还是从头开始,边代码实战边讲比较好一些。

从标题也可以看出,这个系列是希望通过Pytorch这个深度学习框架来了解深度神经网络。当然除了Pytorch之外有许许多多其他深度学习的框架(keras好用到飞起),Pytorch在其中算是比较大众+灵活的。各个框架的优劣在此不做赘述,每个框架自然各有各的好处,这个系列就旨在让大家可以学会用Pytorch训练各种模型,并同时对深度神经网络有一定的认识。当然,我所写的也都是个人的见解,不一定都对,如果有错误也希望大家可以指出。

本文概述

作为开发者,你的第一段代码一定就是HelloWorld。而在Pytorch中也有这么一个跟HelloWorld类似的东西,那便是基于MNIST数据集来做手写体数字的识别。

MNIST数据集是由几万张各不相同的手写体数字组成的一个数据集,每张图片都是28*28像素的一张灰度图。什么是灰度图?即图中每个像素点由[0, 255]这个区间的整数组成。0为纯黑色,255为纯白色,中间的值就是介于黑白之间的灰色,数字越小就越暗,所以称为灰度图。下面是一张样例:
MNIST数据集中的一张样例
作为一个人类,大家一眼就能看出这是数字3。但对机器而言,怎么才能让它知道这是数字几呢?这就是我们今天要干的事情了。

这篇文章作为第一篇,不涉及到任何原理和公式。仅是希望第一次接触Pytorch的同学可以通过实际执行来略窥其使用门道和方法。

准备工作

首先我们需要搭建一个完整的python+pytorch的环境才能开始进入开发。如果已经有环境了的,可以直接跳到下一章节

Python环境搭建

Install Conda

如果你没有任何的Python基础(或者有,但是不多),你可能会问“不是要搭建Python环境吗?咋是从conda开始,这是个啥?”

Conda简单来讲,就是个Python环境管理器。Python是门相当轻量的编程语言,也有茫茫多的轮子可以使用。Conda的意义就在于可以让你在同一台机器上可以同时拥有无数的互相独立的Python环境。这样的好处就是,如果你当前即将使用的轮子的版本和你环境中的其他组件冲突,你也不必卸载你环境中的任何东西,你只需要新建一个conda环境,然后当做一个新环境来安装任何东西就行了。

Conda安装是非常简单的,在官网下载安装即可:Anaconda
安装完之后,在Shell/Cmd/Terminal中,输入以下code即可创建环境:

conda create -n [环境名] python=[python版本]

在这里,我们这么创建:

conda create -n my-torch python=3.9

环境创建完成后,对应版本的Python也即安装进这个环境里了。
接下来,如此这般便可以切换到我们新建的环境

conda activate my-torch

这时候你通过输入python命令来打开python console,应该就可以看到你当前的python版本即为3.9。

然后就是IDE了,大家可以挑选自己喜欢的IDE,我个人使用的是PyCharm,另外推荐的就是用Jupiter Notebook了。其他IDE如果大家自己有用的顺手的(比如vim),大家可以自行使用,这里不做赘述。

安装Pytorch

Pytorch安装起来可以说很简单,也可以说比较复杂。它可以简单到只用一行

pip install torch

即可解决,但你会发现很有可能这么安装下来的pytorch,你只能用cpu来跑模型。所以它也可以复杂到需要你同时顾及到你的显卡版本、cuda版本。如果你使用的是Mac M1芯片的电脑,更需要一些额外的步骤。

好消息是网上对此的解决方案非常多,大家可以根据自己不同的情况来用不同的方式来安装即可。如果不想麻烦,也不嫌CPU跑模型慢,那直接pip install torch来安装也未尝不可。

正式开始

终于可以正式开始了,我们的最终目标是用pytoch来训练一个可以识别手写体数字的模型。为了达成这个目标,其实我们要做的事情也不多,分为这几个部分:

  1. 数据集导入(训练集和验证集)
  2. 神经网络搭建
  3. 单次训练
  4. 扩展到多次训练

接下来让我们一步一步来实现。

数据集导入(训练集和验证集)

MNIST数据集作为一个入门数据集,获取方式非常简单

from torchvision import datasets, transforms

transform = transforms.Compose([
        transforms.ToTensor(),
        transforms.Normalize((0.1307,), (0.3081,))
        ])
dataset_train = datasets.MNIST('./data', train=True, download=True,
                                   transform=transform)
dataset_test = datasets.MNIST('./data', train=False,
                              transform=transform)
print(dataset_train.data.data.shape)
print(dataset_test.data.data.shape)

输出

torch.Size([60000, 28, 28])
torch.Size([10000, 28, 28])

看下获取数据集的参数

  • train: 这个比较简单,True便是训练集,False便是验证集
  • download: 这个参数也没啥,True即是如果再./data中没有该数据集便去下载该数据集。如果下载过于缓慢,可以参考一些网上其他的解决方案,如Pytorch下载MNIST数据集缓慢(官网/网盘)
  • transform: 这里比较容易产生迷惑的就是这个参数,torchvision.transforms这个东西是非常复杂的,简单的说,它会将数据集中的每一张图的数据做N个处理,N=Compose中参数列表的个数。在这里,先做了一次ToTensor,将图片的数据(灰度值)均转为torch中的基本元素tensor。然后做了一次Normalize,感兴趣的同学可以搜一下相关的原理,它就是根据均值和标准差去归一化每个通道值。由于是灰度图,每一个像素点对应一个通道,所以均值和标准差也仅需要传入一个值即可。

在获取到数据集之后,我们还需要将数据集放入一个DataLoader中:

batch_size_train = 128
batch_size_test = 1000

# 训练集设置每次取出的batch_size
train_kwargs = {'batch_size': batch_size_train}
# 验证集设置每次取出的batch_size
test_kwargs = {'batch_size': batch_size_test}
# 一些数据集相关的参数
cuda_kwargs = {
	# worker负责把数据提前加载进内存,这里不做深入讲解,可以先理解为设的越大,加载速度越快,但是开销越大
	'num_workers': 1,
	# 锁页内存,同样可以先理解为如果设为True,速度快,开销大
    'pin_memory': True,
    # 是否随机打乱数据
    'shuffle': True
}
train_kwargs.update(cuda_kwargs)
test_kwargs.update(cuda_kwargs)

train_loader = DataLoader(dataset_train, **train_kwargs)
test_loader = DataLoader(dataset_test, **test_kwargs)

具体的参数的解释已经放在注释里,这里就不在多讲了。主要讲一下DataLoader到底是做什么的。

在上一步中我们已经拿到了数据集,但在Pytorch中,训练模型往往并不是一个for循环遍历数据集便即完成的。我们暂且先不展开优化器等做梯度下降的各种细节,大家可以先理解训练过程中,将数据集切为一份一份的batch再进行训练,对性能开销和效果上是比较友好的。

DataLoader则是对从数据集中取数据做了一个封装。当然,它还可负责数据的读取、预处理等操作,往后会单独对DataLoader进行更细致的讲解。从后文我们可以看到,通过DataLoader可以很方便地对数据集中数据进行读取,并不需要使用者关心其中对内存操作的细节。

神经网络搭建

在读取完数据之后,我们便需要搭建我们的神经网络用于训练了。如果你之前从未接触过神经网络,或者对其中细节不甚了解,先不要太过着急,后面几篇文章我会从头开始对神经网络进行说明。

这里我仅先简单说明下通过神经网络我们要达成的效果,不涉及任何公式。我们先想象在一个平面坐标系下有一些点(如下图)
线性拟合
坐标系的横坐标即为输入,纵坐标即为输出。蓝点即为我们的数据集中的输入输出在坐标轴上的映射。神经网络便是要得到图中的这跟红线(在图中是一条直线,但实际可能是一条曲线),以便有一个数据集之外的输入进来时,我们仍能预测出一个输出。简单来说,神经网络的本质即是做函数的拟合。

这里不做任何展开,我们直接看一下在Pytorch中如何实现一个深度神经网络:

import torch.nn as nn
import torch.nn.functional as F

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        # 第一层卷积神经网络层
        self.conv1 = nn.Conv2d(1, 32, 3, 1)
  		# 第二层卷积神经网络层
        self.conv2 = nn.Conv2d(32, 64, 3, 1)
        # 第一层线性神经网络层
        self.fc1 = nn.Linear(9216, 128)
        # 第二层线性神经网络层,这里要注意,由于我们是手写体数字识别,得到的结果仅会是0~9十个数字,所以最后一层线性层的输出数量便为10
        self.fc2 = nn.Linear(128, 10)

	# 前向传播过程,x即为输入的数据
    def forward(self, x):
    	# 输入至第一层卷积神经网络层,得到输出
        x = self.conv1(x)
        # 输入至RELU激活函数
        x = F.relu(x)
        # 输入至第二层卷积神经网络层,得到输出
        x = self.conv2(x)
        # 输入至RELU激活函数
        x = F.relu(x)
        # 输入至池函数
        x = F.max_pool2d(x, 2)
        # 将二维数据拍平到一维
        x = torch.flatten(x, 1)
        # 输入至第一层线性神经网络层,得到输出
        x = self.fc1(x)
        # 输入至RELU激活函数
        x = F.relu(x)
        # 输入至第二层线性神经网络层,得到输出
        x = self.fc2(x)
        # 输入至log_softmax激活函数,得到最终结果。我们在上一步已经得到了对应0~9的十个结果对应的分数,通过log_softmax即可得到最终的一个最合适的结果
        output = F.log_softmax(x, dim=1)
        return output

说实话,在没有任何基础知识的情况下,看到这个肯定是懵的。尽管我添加了一些注释,但也不是就这一篇文章可以说清的。我们在此先从比较高的层面上讲几个部分。

  1. 整体实现的结构:在构造函数__init__中,我们主要对各个网络层进行了定义,这个不难理解。而forward函数是什么呢?我们之前说了,我们通过训练神经网络最终是希望拟合出一个函数,对于输入便可预测出一个输出。这个forward函数(前向传播函数)即为我们要拟合出的这个函数。对于比较简单的模型来说(比如我们当前要实现的),基本就是将输入一层层放进神经网络层和激活函数中即可。
  2. 输入数据:我们不妨再回忆一下我们的MNIST数据集,它的每个输入均为一个28*28的灰度图,即为(1, 28, 28)的张量。假设我们一个batch处理128张图片,那每次处理的数据即为(128, 1, 28, 28)的张量。
  3. 输出数据:还是以每个batch处理128张图片为前提,在最后一个log_softmax函数结束后,我们得到了一个(128, 10)的张量,即每张图片都对应了10个值。我们可以简单地将这10个值理解为这张图片是0~9这10个结果的分别的概率。我们拿到概率最高的,即可认为是最终的结果

所以简单地说,我们将一组batch的图片数据送入神经网络,通过一次forward前向传播后,即可得到每张图片对应的10个结果分别的概率。

在定义好神经网络后,我们便可以初始化它:

import torch.optim as optim

learning_rate = 1

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# 对于m1芯片的mac,我们如果想用gpu来运行,这应该如此这般:
# device = torch.device("mps")

# 初始化模型
model = Net().to(device)
# 初始化优化器
optimizer = optim.Adadelta(model.parameters(), lr=learning_rate)

等等,似乎出现了一个我们没有见过的新东西,优化器是什么?

如果你经常看一些神经网络相关的文章的话,你一定会见过反向传播这个词。我在此先简单的说明一下。前文已经说了,模型的训练可以看做是函数的拟合。但在一开始,还没有训练过任何数据的时候,我初始的函数里的权重(举个例子,y=kx中的k和b,即斜率和截距)肯定是随机初始化的。在经过一次前向传播得到预测值之后,与训练集中已有的正确答案那必然是会差了很多的。而反向传播即是拿着我这次的结果和正确答案,反过来来调整我当前的权重(还是用y=kx来举例子,如果我发现结果普遍偏大,我可能就会把k调低)。负责反向传播的有两个组件,一个是损失函数,一个是优化器。损失函数负责计算如何调整权重,而负责按照一定的方式实际去调整权重的,即是优化器了。。

优化器有很多种,我们先不一一展开说明。在这个场景我们使用Adadelta优化器即可。

然后我们看下里面的参数,lr指的是学习率Learning Rate。它反应的是每次对权重调整的一个力度。比如还是y=kx来说明,我学习率越小,那我每次反向传播即只对k加减一个很小的值,反之亦反。不同的优化器的学习率的值可能都不太一样,这算是一个经验值,如果遇到模型训练效果不理想,是一个可以拿来调整实验的值(调参侠抓手之一)。

单次训练

准备工作做的差不多了,终于可以来到激动人心的训练环节了。在Pytorch中,单次训练分为这么几步:

  1. 将一组batch的数据的输入和期望输出从DataLoader中取出
  2. 调用模型进行前向传播得到结果
  3. 计算当前模型结果与期望输出的差值,或者更准确点,叫做损失(Loss)
  4. 使用损失函数和优化器进行反向传播并更新权重

步骤不是很多,但是看起来好像有点吓人,又要计算这个又要更新那个。不过不用担心,Pytorch已经对这个训练过程做了一个非常彻底的封装,我们基本无需担心任何复杂的事情:

log_interval = 10

# 步骤一:每次循环取出一个batch的数据
for batch_idx, (data, target) in enumerate(train_loader):
	# 将数据加载到gpu上
    data, target = data.to(device), target.to(device)
    # 梯度归0,这里暂不详细展开,记得加上就行
    optimizer.zero_grad()
    # 步骤二:正向传播得到当前模型的结果
    output = model(data)
    # 步骤三:计算损失
    loss = F.nll_loss(output, target)
    # 步骤四:反向传播并更新权重
    loss.backward()
    optimizer.step()
    # 日志打点
    if batch_idx % log_interval == 0:
        print('Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}'.format(
            epoch, batch_idx * len(data), len(train_loader.dataset),
            100. * batch_idx / len(train_loader), loss.item()))

可以看到,一切复杂的事情在Pytorch中均用一行代码帮你解决了。在上面的代码中,我们用DataLoader每次取出一个batch(batch数量已在DataLoader定义时传入)的数据,并用该数据对模型进行了一次完整的训练。

在将数据集全部训练完之后,我们肯定还想要用验证集来看一下训练的效果。其实也很简单,就是从验证集的DataLoader中取数据,然后前向传播一次,最后计算一次准确率即可:

# 将模型切换到验证模式
model.eval()
test_loss = 0
correct = 0
# 由于是验证,不需要更新模型,这里用with torch.no_grad()来作为开始
with torch.no_grad():
	# 从验证集中加载数据
    for data, target in test_loader:
    	# 将数据加载到gpu上
        data, target = data.to(device), target.to(device)
        # 进行一次前向传播
        output = model(data)
		# 计算损失值        
        test_loss += F.nll_loss(output, target, reduction='sum').item()
        # 根据输出得到和结果格式一致的值
        pred = output.argmax(dim=1, keepdim=True)
        # 比较结果,将正确的数量取出
        correct += pred.eq(target.view_as(pred)).sum().item()

# 计算平均的损失和准确率,并打点
test_loss /= len(test_loader.dataset)
print('\nTest set: Average loss: {:.6f}, Accuracy: {}/{} ({:.3f}%)\n'.format(
            test_loss, correct, len(test_loader.dataset),
            100. * correct / len(test_loader.dataset)))

需要再提一下的是这里得到输出以后对比的地方。这里用了output.argmax,比如对于[0.5, 1, 2, 4, 3.2]的数据,由于4是最大的,则会返回4对应的下标3。在我们的手写体数字识别场景下,如此一来,便获得了最有可能的数字,与原输出作比较,即可算得准确率。

扩展到多次训练

一次训练之后效果可能并不非常理想,我们可能想要训练多轮(epoch)来让模型的效果达到一个不错的效果。这其实是最简单的一步,即在外层再套一层循环即可。

不过要注意的是,并不是训练轮次越多就越好。训练次数太多可能会导致模型的过拟合,也就是模型拟合的函数与测试集太接近了,导致一些测试集之外的数据反而得不到理想的结果。

扩展后的代码如下:

n_epochs = 50

for epoch in range(n_epochs):
    for batch_idx, (data, target) in enumerate(train_loader):
        data, target = data.to(device), target.to(device)
        optimizer.zero_grad()
        output = model(data)
        loss = F.nll_loss(output, target)
        loss.backward()
        optimizer.step()
        if batch_idx % log_interval == 0:
            print('Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}'.format(
                epoch, batch_idx * len(data), len(train_loader.dataset),
                100. * batch_idx / len(train_loader), loss.item()))

    model.eval()
    test_loss = 0
    correct = 0
    with torch.no_grad():
        for data, target in test_loader:
            data, target = data.to(device), target.to(device)
            output = model(data)
            # test_loss += F.cross_entropy(x,target,reduction='sum').item()
            test_loss += F.nll_loss(output, target, reduction='sum').item()
            pred = output.argmax(dim=1, keepdim=True)
            correct += pred.eq(target.view_as(pred)).sum().item()
    test_loss /= len(test_loader.dataset)

    print('Epoch {}: Test set: Average loss: {:.6f}, Accuracy: {}/{} ({:.3f}%)\n'.format(epoch + 1,
            test_loss, correct, len(test_loader.dataset),
            100. * correct / len(test_loader.dataset)))

至此,我们的手写体数字识别模型就可以开始训练了。

训练结果

以下是我用M1芯片的Mac训练了10个epoch跑出来的结果,仅供参考

Epoch 1: Test set: Average loss: 0.050739, Accuracy: 9831/10000 (98.310%)

Epoch 2: Test set: Average loss: 0.035330, Accuracy: 9885/10000 (98.850%)

Epoch 3: Test set: Average loss: 0.032475, Accuracy: 9898/10000 (98.980%)

Epoch 4: Test set: Average loss: 0.033377, Accuracy: 9895/10000 (98.950%)

Epoch 5: Test set: Average loss: 0.032495, Accuracy: 9904/10000 (99.040%)

Epoch 6: Test set: Average loss: 0.039988, Accuracy: 9907/10000 (99.070%)

Epoch 7: Test set: Average loss: 0.034230, Accuracy: 9925/10000 (99.250%)

Epoch 8: Test set: Average loss: 0.038916, Accuracy: 9919/10000 (99.190%)

Epoch 9: Test set: Average loss: 0.040675, Accuracy: 9921/10000 (99.210%)

Epoch 10: Test set: Average loss: 0.040033, Accuracy: 9927/10000 (99.270%)

最终在验证集上的准确率在99.270%,看着还不错。

结语

恭喜你跟着来到了最后,经过这番尝试,相信你也成功训练出了一个不错的基于MNIST的手写体数字识别模型。但这只是个开始,我们才刚刚完成了我们的HelloWorld。

我们最后简单回顾一下,我们本次主要还是对Pytorch的使用有了一定的了解。首先是MNIST数据集的加载,然后是神经网络的搭建,最后是模型训练。对于大部分神经网络模型来说,整体的步骤其实也大致就是如此。

之后我会从一些非常基础的知识开始,从零开始介绍神经网络和Pytorch,敬请大家期待下一章了。

下一篇内容:入门级深度神经网络 with Pytorch(2) - 线性网络(一)

完整代码

import torch
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim

n_epochs = 10
batch_size_train = 128
batch_size_test = 1000
learning_rate = 1
log_interval = 10
random_seed = 1
torch.manual_seed(random_seed)

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1 = nn.Conv2d(1, 32, 3, 1)
        self.conv2 = nn.Conv2d(32, 64, 3, 1)
        self.dropout1 = nn.Dropout(0.25)
        self.dropout2 = nn.Dropout(0.5)
        self.fc1 = nn.Linear(9216, 128)
        self.fc2 = nn.Linear(128, 10)

    def forward(self, x):
        x = self.conv1(x)
        x = F.relu(x)
        x = self.conv2(x)
        x = F.relu(x)
        x = F.max_pool2d(x, 2)
        x = self.dropout1(x)
        x = torch.flatten(x, 1)
        x = self.fc1(x)
        x = F.relu(x)
        x = self.dropout2(x)
        x = self.fc2(x)
        output = F.log_softmax(x, dim=1)
        return output


if __name__ == '__main__':
    device = torch.device("mps")

    train_kwargs = {'batch_size': batch_size_train}
    test_kwargs = {'batch_size': batch_size_test}

    cuda_kwargs = {'num_workers': 1,
                   'pin_memory': True,
                   'shuffle': True}
    train_kwargs.update(cuda_kwargs)
    test_kwargs.update(cuda_kwargs)

    transform = transforms.Compose([
        transforms.ToTensor(),
        transforms.Normalize((0.1307,), (0.3081,))
        ])
    dataset_train = datasets.MNIST('./data', train=True, download=True,
                                   transform=transform)
    dataset_test = datasets.MNIST('./data', train=False,
                                  transform=transform)
    train_loader = DataLoader(dataset_train, **train_kwargs)
    test_loader = DataLoader(dataset_test, **test_kwargs)

    model = Net().to(device)
    optimizer = optim.Adadelta(model.parameters(), lr=learning_rate)
    # optimizer = optim.SGD(model.parameters(), lr=1)

    for epoch in range(n_epochs):
        for batch_idx, (data, target) in enumerate(train_loader):
            data, target = data.to(device), target.to(device)
            optimizer.zero_grad()
            output = model(data)
            loss = F.nll_loss(output, target)
            loss.backward()
            optimizer.step()
            # if batch_idx % log_interval == 0:
            #     print('Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}'.format(
            #         epoch, batch_idx * len(data), len(train_loader.dataset),
            #         100. * batch_idx / len(train_loader), loss.item()))

        model.eval()
        test_loss = 0
        correct = 0
        with torch.no_grad():
            for data, target in test_loader:
                data, target = data.to(device), target.to(device)
                output = model(data)
                # test_loss += F.cross_entropy(x,target,reduction='sum').item()
                test_loss += F.nll_loss(output, target, reduction='sum').item()
                pred = output.argmax(dim=1, keepdim=True)
                correct += pred.eq(target.view_as(pred)).sum().item()
        test_loss /= len(test_loader.dataset)

        print('Epoch {}: Test set: Average loss: {:.6f}, Accuracy: {}/{} ({:.3f}%)\n'.format(epoch + 1,
            test_loss, correct, len(test_loader.dataset),
            100. * correct / len(test_loader.dataset)))



  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值