0%

机器学习领域的Hello World - 从零实现手写数字识别

校内的科普讲座中讲到了手写数字识别,可以说是所有炼丹的起点了,这里整理一下实现的过程,为新入门的同学们提供参考。

环境准备

程序在python3.8的环境下运行,需要在环境中下载以下包

1
2
3
# 以下为我自己的设备安装的指令
# 根据自身需求在https://pytorch.org/get-started/locally/中复制下载
conda install pytorch torchvision torchaudio pytorch-cuda=11.8 -c pytorch -c nvidia

原理介绍

本文使用的手写数字识别项目没有使用任何深层次技术,使用最简单粗暴的多层感知机来实现,图像方面也没有经过任何预处理,算法也使用的最简单的入门算法,主要为了让新手更快上手。这里简单介绍一下模型和算法

算法原理

我们可以将模型堪称一个黑盒子,黑盒子里由无数旋钮来配置这个盒子的信息。盒子由输入和输出两部分组成,就像最简单的y=x的函数一样,我们放进去一个数据,就能得到一个数据,而数据的产生就是由这些旋钮控制的。

最开始盒子里的旋钮是杂乱的随机数,我们怎没来让旋钮设置出准确的结果呢?我们准备一个输入和理应得到的正确答案,根据输入得到输出后和正确答案进行比较,根据二者的差距来调整旋钮,就像调整天平一样。

上文中,黑盒子指的就是模型,旋钮指的就是模型的参数。我们使用正确答案来调整模型这一过程我们称之为训练。人们认为多个线性层组成的曲线函数可以拟合现实中我们不知道的函数从而得到正确结果,训练就是让模型函数变得更贴合现实函数的过程。

原理分析与代码讲解

导入库文件

我们如果从零开始写一个文件,其中的各种方法例如求导、矩阵运算等写起来费时费力不说,这些都是前辈们设计好的东西。我们为了避免造轮子,于是使用pytorch这个深度学习框架来进行作业。以下是我们用到的所有库文件。

1
2
3
4
5
6
7
8
# 导入库文件
import torch # pytorch
from torchvision.datasets import MNIST # MNIST数据集
from torchvision import transforms # 图像预处理操作
from torch.utils.data import DataLoader # 数据加载器
from torch import nn # 神经网络
import os # 操作系统相关操作
from PIL import Image # 图片操作

模型设计

模型的构成方式有很多,最简单的例如一个y=x都可以称之为一个模型,因为模型的本质就是函数。我们这里的模型之所以叫做多层感知机是因为他是使用最简单的线性层来组成的。

线性层是什么呢?就像他的名字一样,是由线性代数中的矩阵组成的数据。每个线性层可以定义输入n个数据和输出m个数据,就像nxm的矩阵一样,我们可以将1xn的矩阵和线性层矩阵相乘得到1xm的矩阵,这些数据和输入数据进行矩阵相乘的运算,得到一组新的数据传播到下一层或者是输出,具体为何能这么运算大家可以复习一下线性代数中矩阵的知识点。

我们的数据是28x28的图片,也就是784个由0·255组成的矩阵数据。为了与之对应,我们使用了三层线性层,分别从784层映射到256层,从256层映射到64层,从64层映射到10层(因为我们有总计十个分类的图片集合)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Model(nn.Module): # pytorch要求我们设计模型时继承Module这个类
# 这里初始化模型参数
def __init__(self):
super(Model, self).__init__()
self.linear1 = nn.Linear(784, 256)
self.linear2 = nn.Linear(256, 64)
self.linear3 = nn.Linear(64, 10) # 10个数字输出为10

# 这里定义模型的输出
def forward(self, x):
x = x.view(-1, 784) # 将28x28的矩阵拉伸为1x784的矩阵
x = torch.relu(self.linear1(x))
x = torch.relu(self.linear2(x))
x = torch.relu(self.linear3(x))
return x

# 构建模型,将模型放入现存
net = Model()
net.to(torch.device("cuda"))

这里的relu是激活函数,激活函数是一种用来引入非线性因素的函数,否则我们的输出永远都是一个线性方程,为了让大家不要一次性吸收太多知识,这里不过多解释。

定义超参数

我们刚刚降到了参数是用来控制模型输出的旋钮,那么我们每次调整旋钮的方式便可以理解为超参数,即人为控制的,在参数之上的参数。我们反复调整超参数的行为也就叫做炼丹。

学习率,表示的是每次调整旋钮旋转的距离,如果每次转的太小那我们很久之后才能找到最合适的位置,如果每次转的太大那我们可能一直在最合适位置的左右反复横跳。

损失函数,即计算输出值和正确答案损失了多少的函数,对比黑盒子比喻中比较的部分。损失函数由很多种,比如说我们可以简单暴力的求二者差的绝对值,因为他直观地代表了两者的差距。这里使用的交叉熵损失不作详细解释。

1
2
3
4
5
lr = 0.1 # 学习率 即每次学习更新的距离
num_epochs = 30 # 训练次数 即进行多少次训练
batch_size = 128 # 每次训练时取出的数据数量
loss = nn.CrossEntropyLoss() # 损失函数 交叉熵损失 相当于softmax + log + NLLLoss
opt = torch.optim.SGD(net.parameters(), lr=lr) # 优化器 梯度下降优化 是一种根据导数方向决定正负的调整方法

加载数据集

数据集,即数据的集合,pytorch为我们提供了数据加载器这个类来处理数据,在分类任务中我们需要将许多图片划分为0-9九个分类。数据集中的原始数据自然是图片本身,即28x28的像素图片,除此之外还应该有这张图具体代表哪个数字,来让计算机判断。其中,我们一般将数据集分为训练集和测试集,前者负责训练而后者负责测试模型性能。

因为图片的存储是由许多像素点组成,为了让他们转换为我们方便操作的pytorch使用的数据类型,我们需要对他进行预处理,我们可以定义一个transform函数来预处理图片数据。

1
2
3
4
5
6
7
8
9
10
# 数据预处理
trans = transforms.Compose([
transforms.ToTensor(), # 将0-255的PIL文件转换为0-1的tensor文件
])

# 加载数据集
train_data = MNIST(root='./data', train=True, download=False, transform=trans) # 载入训练数据集
train_iter = DataLoader(train_data, shuffle=True) # 载入训练数据加载器
test_data = MNIST(root='./data', train=False, download=False, transform=trans) # 载入测试数据集
test_iter = DataLoader(test_data, shuffle=True) # 载入测试数据加载器

注意这里的download指的是是否下载,因此第一次下载就够了,之后直接读取就好,数据下载在data文件夹里。我们可以查看一下数据集中的一些数据

1
2
3
4
5
6
# 查看数据集中的数据
for i, (X, Y) in enumerate(train_iter):
print(i)
print(X)
print(Y)
break

训练模型

训练模型可以理解为每次调整旋钮的过程,即可以分成运算结果,查找差距,调整参数三个部分。训练完我们可以在测试集上验证我们的模型的准确性,具体过程详见代码及注释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def train():
print("训练开始")
for epoch in range(num_epochs):
# 训练部分
print("第", epoch, "轮训练开始!")
for index, (x, y) in enumerate(train_iter):
x, y = x.to(torch.device("cuda")), y.to(torch.device("cuda"))
opt.zero_grad() # 梯度清零
y_ = net(x) # 使用模型运行数据
l = loss(y_, y) # 计算损失
if index % 100 == 0: # 没训练100个整数倍个数据时,输出当前loss值
print(f'loss = {l.item()}')
l.backward() # 梯度下降,反向传播求导
opt.step() # 模型参数更新
print("第", epoch, "轮训练结束!")
# 测试部分
correct = 0 # 正确的个数
total = 0 # 总数
with torch.no_grad():
for index, (x, y) in enumerate(test_iter):
x, y = x.to(torch.device("cuda")), y.to(torch.device("cuda"))
out = net(x)
_, crr = torch.max(out.data, dim=1)
total += y.size(0) # 计算总数
correct += (crr == y).sum().item() # 计算正确的个数
print(f'识别率为:{correct / total}')
torch.save(net.state_dict(), "./model/model.pkl") # 存储模型 注意提前建立好model文件夹
print("训练结束")

应用测试

接下来我们尝试写一个简单应用来验证一下我们的模型是否正确,我们将通过画图软件使用黑白两种颜色来制作一个28x28的图片,写上一个数字命名为number.png(注意是黑色背景),用python读取之后让模型来判断是否正确。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def app():
net.load_state_dict(torch.load("./model/model.pkl")) # 读取模型参数
img = Image.open("number.png").convert("L") # 读取图片
# img.show() # 展示图片
img = trans(img) # 图片初始化
img.view(-1, 784) # 图片拉伸为1x784的矩阵
img = img.to(torch.device('cuda'))
result = net(img)
a, res = torch.max(result.data, dim=1)
print("识别结果为: ", res.item())

# 我们可以写一个main函数来控制训练和应用两部分的使用
if __name__ == '__main__':
# train()
app()

总结

至此,我们已经完成了数字手写识别的编码,也完成了这个hello world的入门工程。经过几次测试可以看出,模型并不能很准确的识别出信息,就像我们作为人也不能从一个1x784的矩阵中判断出一个数字一样,这种编码失去了很多有用信息,如空间信息等。至于如何有效利用这些信息,就是大家日后深入了解的部分了。

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# 文件名称:MNIST数据集训练数字识别
# 创建日期:2023/10/18
# 更新如期:2023/10/18
# 包依赖:torch

# 导入库文件
import torch # pytorch
from torchvision.datasets import MNIST # MNIST数据集
from torchvision import transforms # 图像预处理操作
from torch.utils.data import DataLoader # 数据加载器
from torch import nn # 神经网络
import os # 操作系统相关操作
from PIL import Image # 图片操作

# 模型设计
class Model(nn.Module): # pytorch要求我们设计模型时继承Module这个类
# 这里初始化模型参数
def __init__(self):
super(Model, self).__init__()
self.linear1 = nn.Linear(784, 256)
self.linear2 = nn.Linear(256, 64)
self.linear3 = nn.Linear(64, 10) # 10个数字输出为10

# 这里定义模型的输出
def forward(self, x):
x = x.view(-1, 784) # 将28x28的矩阵拉伸为1x784的矩阵
x = torch.relu(self.linear1(x))
x = torch.relu(self.linear2(x))
x = torch.relu(self.linear3(x))
return x

# 构建模型,将模型放入现存
net = Model()
net.to(torch.device("cuda"))

# 定义超参数
lr = 0.1 # 学习率 即每次学习更新的距离
num_epochs = 30 # 训练次数 即进行多少次训练
batch_size = 128 # 每次训练时取出的数据数量
loss = nn.CrossEntropyLoss() # 损失函数 交叉熵损失 相当于softmax + log + NLLLoss
opt = torch.optim.SGD(net.parameters(), lr=lr) # 优化器 梯度下降优化 是一种根据导数方向决定正负的调整方法

# 加载数据集
# 数据预处理
trans = transforms.Compose([
transforms.ToTensor(), # 将0-255的PIL文件转换为0-1的tensor文件
])

# 加载数据集
train_data = MNIST(root='./data', train=True, download=False, transform=trans) # 载入训练数据集
train_iter = DataLoader(train_data, shuffle=True, batch_size=batch_size) # 载入训练数据加载器
test_data = MNIST(root='./data', train=False, download=False, transform=trans) # 载入测试数据集
test_iter = DataLoader(test_data, shuffle=True, batch_size=batch_size) # 载入测试数据加载器

# # 查看数据集中的数据
# for i, (x, y) in enumerate(train_iter):
# print(i)
# print(x)
# print(y)
# break

# 训练模型
def train():
print("训练开始")
for epoch in range(num_epochs):
# 训练部分
print("第", epoch, "轮训练开始!")
for index, (x, y) in enumerate(train_iter):
x, y = x.to(torch.device("cuda")), y.to(torch.device("cuda"))
opt.zero_grad() # 梯度清零
y_ = net(x) # 使用模型运行数据
l = loss(y_, y) # 计算损失
if index % 100 == 0: # 没训练100个整数倍个数据时,输出当前loss值
print(f'loss = {l.item()}')
l.backward() # 梯度下降,反向传播求导
opt.step() # 模型参数更新
print("第", epoch, "轮训练结束!")
# 测试部分
correct = 0 # 正确的个数
total = 0 # 总数
with torch.no_grad():
for index, (x, y) in enumerate(test_iter):
x, y = x.to(torch.device("cuda")), y.to(torch.device("cuda"))
out = net(x)
_, crr = torch.max(out.data, dim=1)
total += y.size(0) # 计算总数
correct += (crr == y).sum().item() # 计算正确的个数
print(f'识别率为:{correct / total}')
torch.save(net.state_dict(), "./model/model.pkl") # 存储模型 注意提前建立好model文件夹
print("训练结束")

# 应用测试
def app():
net.load_state_dict(torch.load("./model/model.pkl")) # 读取模型参数
img = Image.open("number.png").convert("L") # 读取图片
# img.show() # 展示图片
img = trans(img) # 图片初始化
img.view(-1, 784) # 图片拉伸为1x784的矩阵
img = img.to(torch.device('cuda'))
result = net(img)
a, res = torch.max(result.data, dim=1)
print("识别结果为: ", res.item())

# 我们可以写一个main函数来控制训练和应用两部分的使用
if __name__ == '__main__':
# train()
app()