pytorch-MNIST手写体识别,特征图可视化,自己动手搭建神经网络模型
1、前言
这篇博客的主要目的,是记录自己的深度学习的学习过程。在做完这个mnist手写体识别之后,能明显的感觉到对深度学习的理解更加深刻、对流程更加了解。关于MNIST手写体识别的博客,网上数不胜数,但是:
深入理解MNIST识别程序,并且进行拓展比如特征图可视化、学着自己搭建神经网络的却很少,
所以打算好好总结一下这次学习过程,也希望能帮助到其他小伙伴。篇幅较长,可以根据需要点击目录观看。
之前刚开始做的时候写过一篇pytorch-mnist的博客,原文链接是:
https://blog.csdn.net/qq_24489997/article/details/110432634?spm=1001.2014.3001.5502
现在这一篇算是一个补充完整版吧。附上GitHub代码链接:
https://github.com/cssdcc1997/pytorch-mnist
如果小伙伴们觉得有帮助的话别忘了点个star
哦!
2、MNIST数据集
似乎所有程序员在学习一个新的程序语言时,都想要打印输出一个“hello world”,它代表了你入门了这门语言。那么,MNIST手写数字识别便是入门机器学习和深度学习的“hello world”。跑通MNIST程序便能大致了解机器学习的流程,包括数据的读取、转换(totensor)、归一化、神经网络模型设计、超参数设计、训练、前向传播、后向传播等等。在入门机器学习之前先自己跑通一遍MNIST识别程序具有非凡的意义。
MNIST
(Mixed National Institute of Standards and Technologydatabase)是一个手写数字的大型数据库,拥有60,000个示例的训练集和10,000个示例的测试集。更详细的介绍可以查看 Yann LeCun的MNIST数据集官网。
3、深度学习最基本流程
下面是深度学习基本流程的伪代码:
data = get_data() # 1、获取数据
transformed_data = transform(data) # 2、将数据进行预处理
for image in transformed_data: # 遍历数据集
result = model(image) # 3、将数据送入模型,模型输出结果
loss = compute_loss(result, target) # 4、计算loss损失
loss.backward() # 5、进行反向传播
optimizer.step() # 6、优化器更新网络
save(model) # 7、训练完毕,保存模型
深度学习的基本流程大致可以分为7步:①
获取数据;②
数据预处理;③
数据送入模型,得到输出;④
根据输出计算loss;⑤
根据loss进行反向传播;⑥
优化器更新网络参数;⑦
训练完毕保存模型。
具体细节这篇博客就不细讲啦,我觉得初学者想学习深度学习也可以从这7步入手,比如:
- 如何对数据进行预处理?裁剪、缩放以及数据增强的方法。
- 如何设计网络、选择网络?
- 常用的loss函数有哪些?
- 反向传播的具体实现,是否能手写出反向传播的过程?
- 优化器如何选择?都有哪些优缺点?
- 优化器如何更新参数?
- 什么时候保存模型最好?
4、代码
程序根据pytorch官方提供的MNIST示例代码进行修改,链接:
https://github.com/pytorch/examples/blob/master/mnist/main.py
在经过修改后,添加了训练结果可视化和特征图可视化等功能,并且尝试自己设计神经网络模型,GitHub链接上面已经给出啦。
下面讲解train.py
中的代码:
4.1 导入模块
from __future__ import print_function
import argparse
import os
#import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torchvision import datasets, transforms
from torch.optim.lr_scheduler import StepLR
from pathlib import Path
import time
# import network
from model.network.LeNet import LeNet
from model.network.MyNetV1 import MyNetV1
from model.network.MyNetV2 import MyNetV2
from model.network.DefaultNet import DefaultNet
from model.network.MyFullConvNet import MyFullConvNet
from model.network.MyVggNet import MyVggNet
from model.network.NewNet import NewNet
导入训练网络需要的模块,其中值得注意的是:
- argparse模块,该模块允许你在运行
.py
文件时可以附带参数,如:python train.py --model lenet
- torch基本模块,即pytorch基本的库
- matplotlib模块,用于绘制loss曲线和acc曲线图,也用于显示模型中各层特征图即特征图可视化
4.2 函数参数
通过argparse
模块,可以在运行文件时添加运行所需要的参数。这些参数可以用于设置网络模型的超参数,如学习率
、batch-size
、epochs
、训练模型等等。下面贴出代码:
# Training settings
parser = argparse.ArgumentParser(description="Pytorch MNIST Example")
parser.add_argument("--batch-size", type=int, default=64, metavar="N",
help="input batch size for training (default : 64)")
parser.add_argument("--test-batch-size", type=int, default=1000, metavar="N",
help="input batch size for testing (default : 1000)")
parser.add_argument("--epochs", type=int, default=64, metavar="N",
help="number of epochs to train (default : 64)")
parser.add_argument("--learning-rate", type=float, default=0.1, metavar="LR",
help="the learning rate (default : 0.1)")
parser.add_argument("--gamma", type=float, default=0.5, metavar="M",
help="Learning rate step gamma (default : 0.5)")
parser.add_argument("--use-cuda", action="store_true", default=True,
help="Using CUDA training")
parser.add_argument("--dry-run", action="store_true", default=False,
help="quickly check a single pass")
parser.add_argument("--seed", type=int, default=1, metavar="S",
help="random seed (default : 1)")
parser.add_argument("--log-interval", type=int, default=10, metavar="N",
help="how many batches to wait before logging training status")
parser.add_argument("--save-model", action = "store_true", default=True,
help="For saving the current Model")
parser.add_argument("--load_state_dict", type=str, default="no",
help="load the trained model weights or not (default: no)")
parser.add_argument("--model", type=str, default="LeNet",
help="choose the model to train (default: LeNet)")
args = parser.parse_args()
值得注意的是:
- batch-size:批训练大小,单次训练用的样本数。通常以2^n为大小。如果batch-size过小,就好像你每次数钱只数一张(而不是好几张一起数),训练数据效率就低下,且收敛困难;如果batch-size过大,虽然相对处理速度加快,但是所需要的内容容量增加,可能会出现 CPU/GPU 内存容量不足等情况,所以需要根据图片具体大小、模型复杂度和计算机性能之间权衡batch-size的大小
- epochs:一个epoch表示所有的数据送入网络中完成一次前向传播和后向传播的过程
- leaning-rate:学习率
- load_state_dict:继续模型训练/重新开始训练。假设上次的训练效果不理想,你想在上次的基础上继续训练,就可以添加这个选项,训练前加载之前生成的权重文件
- gamma:调整学习率中所用的参数,调整方法为StepLR
其他参数大概就是字面意思。要注意必须有args = parse.parse_args()
这一句,意思是把爬取到的参数信息赋值到变量args上,后续便可以通过args得到参数值,比如args.model, args.learning_rate。
4.3 数据加载与transform
以下代码是程序加载数据和对数据进行转化(ToTensor)的代码:
train_kwargs = {"batch_size": args.batch_size}
test_kwargs = {"batch_size": args.test_batch_size}
if use_cuda:
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(),
# normalize(mean, std, inplace=False) mean各通道的均值, std各通道的标准差, inplace是否原地操作
# 这里说的均值是数据里的均值
# output = (input - mean) / std
# 归一化到-1 ~ 1,也不一定,但是属于标准化
transforms.Normalize((0.1307, ), (0.3081, ))
])
dataset1 = datasets.MNIST("./data", train=True, download=True,
transform=transform)
dataset2 = datasets.MNIST("./data", train=False,
transform=transform)
train_loader = torch.utils.data.DataLoader(dataset1, **train_kwargs)
test_loader = torch.utils.data.DataLoader(dataset2, **test_kwargs)
num_workers
是多进程的加载数,pin_memory
是是否将数据保存在pin memory
区,pin memory
中的数据转到CPU会比较快。另外几个值得注意的点是:
1. 加载数据集
- train=True即加载训练集,false即加载训练集
- download即是否下载数据集,如果数据集不存在,则代码会自动下载数据集到指定路径中;若存在,则略过
- shuffle是将数据打乱顺序送入网络(由于数据可能存在连续性,比如人脸识别中同一个人的图片是连续的,连续的将同一个的数据送入网络可能会导致训练结果较差)。
2. 数据转换与归一化
- 数据原本是二进制文件,通过transform将其转换成可训练的tensor张量
- 因为数据集都是一通道的黑白图片,像素值为0-255,为了方便计算,需要将其归一化,这样做可以让收敛更快。其中
mean
是数据里的均值,std
是各通道的标准差
4.4 训练
graph_loss = []
graph_acc = []
def train(args, model, device, train_loader, test_loader, optimizer, scheduler):
# 这里的train和上面的train不是一个train
model.train()
best_epoch = 0
best_acc = 0
for epoch in range(args.epochs):
start_time = time.time()
for batch_idx, (data, target) in enumerate(train_loader):
tmp_time = time.time()
data, target = data.to(device), target.to(device)
optimizer.zero_grad() # 优化器梯度为什么初始化为0?
output = model(data)
loss = F.nll_loss(output, target)
loss.backward()
optimizer.step()
if batch_idx % args.log_interval == 0:
print("Train Epoch: {} [{}/{} ({:.0f}%)]\t Loss: {:.6f}\t Cost time: {:.6f}s".format(
epoch + 1, batch_idx * len(data), len(train_loader.dataset),
100. * batch_idx / len(train_loader), loss.item(), time.time() - tmp_time
))
graph_loss.append(loss.item())
if args.dry_run:
break
scheduler.step()
end_time = time.time()
print("Epoch {} cost {} s".format(epoch + 1, end_time - start_time))
acc = test(model, device, test_loader)
if acc > best_acc:
best_acc = acc
best_epoch = epoch + 1
if args.save_model:
torch.save(model.state_dict(), "./model/weights/{}.pt".format(args.model))
print("Best epoch: {} | Best acc: {}".format(best_epoch, best_acc))
定义全局变量graph_loss
和 graph_acc
分别记录训练过程中的准确率和损失,最后写入.txt文件中,方便后续查看和数据可视化。简单的说一下训练的流程:
- 从dataloader中获取数据集的数据和与之对应的标签,即data,target,并放到device中(CPU/GPU)
- 初始化optimizer的梯度为0
- 数据送入模型处理
- 根据输出和实际标签计算loss值。这里采用的损失函数是nll_loss,也是一种交叉熵损失函数。它和CrossEntropyLoss的区别是,nll_loss没有包含softmax这一步,所以它适合模型结尾带有softmax的网络
- 根据损失值进行后向传播
- 后向传播的工具是优化器,优化器开始后向传播,更新模型参数
- 每隔一个log_interval输出当前的训练结果,比如损失值、当前epoch完成百分比和时间
仔细的理解训练过程的每一步,便知道机器学习的原理大概是怎么样的
值得注意的是:
官方自带的mnist识别示例程序,是在训练结束时保存模型,但是模型的训练过程中,不一定在最后一个epoch效果最好,也基本不可能在最后一个epoch,一般会在训练过程中间,模型收敛到局部最优点。
所以,在训练过程中进行指标的判断,一旦优于上一个(训练过程中)最好的指标,便保存模型。
if acc > best_acc:
best_acc = acc
best_epoch = epoch + 1
if args.save_model:
torch.save(model.state_dict(), "./model/weights/{}.pt".format(args.model))
4.5 自己搭建模型
深度学习入门门槛低,很大一部分原因是因为深度学习框架做得好,比如pytorch
、tensorflow
、caffe
等等。这些深度学习框架将每一个可能需要用到的功能都进行了封装,想要的模块只要调用官方库里的函数即可。所以对于初学者来说,神经网络模型的搭建,就和搭积木一样简单。
在搭建之前,首先要有一些基本知识:
- 卷积神经网络的基本模块:① 卷积层;②池化层;③ 全连接层。
- 一张图片(tensor)在经过上述模块后,图片的深度、高、宽(tensor张量的几个维度)分别发生了什么变化?
以上所提到的知识点在网上或者深度学习书籍上都能轻易的找到并且学习,这里就不细讲啦。
有了以上基本知识,就可以自己动手搭建网络啦。
先看看最最经典的神经网络LeNet。
以上是被用到烂的LeNet模型结构图。它的模型输入大小是32 x 32,本程序是28 x 28.
如下为代码model/network/LeNet.py
:
class LeNet(nn.Module):
def __init__(self):
super(LeNet, self).__init__()
self.conv1 = nn.Conv2d(1, 6, 5) # nn.Conv2d(3, 6, 5)
self.conv2 = nn.Conv2d(6, 16, 5)
#nn.init()
self.fc1 = nn.Linear(16 * 4 * 4, 120)
self.fc2 = nn.Linear(120, 84)
self.fc3 = nn.Linear(84, 10)
def forward(self, x):
x = self.conv1(x)
x = F.relu(x)
x = F.max_pool2d(x, 2)
x = self.conv2(x)
x = F.relu(x)
x = F.max_pool2d(x, 2)
x = x.view(x.size(0), -1)
x = self.fc1(x)
x = F.relu(x)
x = self.fc2(x)
x = F.relu(x)
x = self.fc3(x)
x = F.log_softmax(x, dim=1)
return x
一步一步讲解:
- 网络的第一层是卷积层,
输入通道
是1,输出通道
是6,卷积核大小
是5 - 第二层是池化层,
下采样倍数
为2 - 第三层是卷积层,
输入通道
是6,输出通道
是16,卷积核大小
是5 - 第四层是池化层,
下采样倍数
为2 - 第5-7层是全连接层。
- 每个卷积层后面都跟一个激活函数
relu
代码结构、网络结构一目了然。只要搞清楚张量在送入网络后经过每一层的变化,便能仿照LeNet对三个基本模块卷积层、池化层、全连接层当做积木一样随机排列组合,只要参数能对应上,就能进行训练、测试、识别。当然也不是真的“随机”排列组合啦
我在学习初期,因为觉得好玩,自己设计了几个,效果都要比LeNet好,下面也有讲解,感兴趣的小伙伴可以点进github里看看,然后自己也尝试着设计一下。下面贴出最近搭的这个网络,测试准确度能达到99.5%
,model/network/NewNet.py
:
class NewNet(nn.Module):
def __init__(self):
super(NewNet, self).__init__()
self.features = nn.Sequential(
# 28*28
nn.Conv2d(1, 16, 3, 1, 1),
nn.BatchNorm2d(16),
nn.PReLU(16),
## 14*14
nn.Conv2d(16, 16, 3, 2, 1),
nn.BatchNorm2d(16),
nn.PReLU(16),
nn.Dropout(0.2),
nn.Conv2d(16, 64, 3, 1, 1),
nn.BatchNorm2d(64),
nn.PReLU(64),
## 7*7
nn.Conv2d(64, 64, 3, 2, 1),
nn.BatchNorm2d(64),
nn.PReLU(64),
nn.Dropout(0.2),
nn.Conv2d(64, 128, 3, 1, 1),
nn.BatchNorm2d(128),
nn.PReLU(128),
## 4*4
nn.Conv2d(128, 128, 3, 2, 1),
nn.BatchNorm2d(128),
nn.PReLU(128),
nn.Dropout(0.2),
## down sampling
## 2*2
nn.Conv2d(128, 96, 3, 2, 1),
nn.BatchNorm2d(96),
nn.PReLU(96),
nn.Dropout(0.2),
## 1*1
nn.Conv2d(96, 10, 3, 2, 1),
nn.BatchNorm2d(10),
nn.PReLU(10),
nn.Dropout(0.2)
)
def forward(self, x):
x = self.features(x)
x = x.view(x.size(0), -1)
x = F.log_softmax(x, dim=1)
return x
能看到,这个NewNet
和LeNet有一些区别,下面简单提一下:
-
卷积核大小
全部为3,通过验算便可知道,两个3 x 3的卷积核的感受野等同于一个5 x 5的卷积核,并且占用的参数量更少。 - 使用了BatchNormalize技术
- 将最普通的relu替换为prelu,效果更佳
- 抛弃池化层、全连接层,全部使用卷积层进行代替。当卷积核的步长stride为2或更高时,便能达到
下采样
的作用,且不会有池化层丢失信息的缺点。全连接层占用参数较大,使用卷积层代替可以减小模型大小。
虽然说搭建神经网络非常简单,就像搭积木一样,但是造积木却非常的难。初学小伙伴们要加油打怪升级啦!
4.6 测试
测试训练结果在代码在test_model.py
里,测试内容是测试集的前1000张图片(这个数字可以在argparse里面修改)。输入:
python test_model.py --model lenet
便得到测试结果:
5、训练结果可视化
在训练的过程中,我将训练的结果存放在graph_loss
和graph_acc
里,并且在训练结束之后,将两个列表中的数据存储到.txt文件当中。现在,便可以从.txt文件中读取训练结果并显示出来。用于画图的工具是matplotlib,而相关的代码文件是draw_graph.py,以下为代码:
import matplotlib
import os
import numpy as np
from matplotlib import pyplot as plt
import argparse
import sys
parser = argparse.ArgumentParser()
parser.add_argument("--model", type=str, default="lenet")
args = parser.parse_args()
#file_loss_path = "E:/WorkSpace/Pytorch/mnist/model/result/{}_loss.txt".format(args.model)
file_loss_path = sys.path[0] + "/model/result/{}_loss.txt".format(args.model)
lst_loss = list()
with open(file_loss_path) as file_object:
for line in file_object:
if "e" in line:
lst_loss.append(eval(line))
else:
lst_loss.append(float(line[:-2]))
file_object.close()
#file_acc_path = "E:/WorkSpace/Pytorch/mnist/model/result/{}_acc.txt".format(args.model)
file_acc_path = sys.path[0] + "/model/result/{}_acc.txt".format(args.model)
lst_acc = list()
with open(file_acc_path) as file_object:
for line in file_object:
if "e" in line:
lst_acc.append(eval(line))
else:
lst_acc.append(float(line[:-2]))
file_object.close()
print(lst_acc)
plt.title("{} loss".format(args.model))
plt.plot(lst_loss)
plt.xlim(0 - len(lst_loss) / 20, len(lst_loss))
plt.ylim(0, 1.5)
plt.grid()
plt.savefig(file_loss_path[:-3] + "jpg")
plt.title("{} acc".format(args.model))
plt.plot(lst_acc)
plt.xlim(0 - len(lst_acc) / 20, len(lst_acc))
plt.ylim(min(lst_acc) - 1, max(max(lst_acc) + 1, 100))
plt.savefig(file_acc_path[:-3] + "jpg")
通过matplotlib,读取.txt文件中的数据,将其以图表的形式显示并且保存下来,以下为效果:
64个epcoh的训练准确率:
训练过程中的损失:
5.1 多模型准确率汇总
在学习本程序的过程中,我也在学习一些经典网络,比如LeNet, AlexNet, VggNet等,所以尝试着自己搭建网络并将经典网络中的优点融入其中,以下为不同网络准确率:
- LeNet,最经典的网络,包含了基本的卷积神经网络结构。可见由于其网络结构过于简单,其准确率有较大的抖动,但是有不错的准确率
- MyFullConvNet,运用了更多的卷积层,并且用卷积层代替第一个全连接层,效果比LeNet要好
- MyNetV1,这也是我自己设计的网络结构,其中的设计思想是:① 尽可能的使用小卷积核,参数量更少; ② 使用stride = 2的卷积核来代替池化层,这样虽然参数量有所增加,但是弥补了池化层会过滤掉过多信息的缺点; ③ 使用dropout技术,使网络降低过拟合。
- MyNetV2,本来是想用卷积层代替更多的其他层,于是用了输出通道数比输出通道数小的卷积层,但结果并不理想,甚至比LeNet准确率还要低
- MyVggNet,参考了VggNet的特性修改的网络,用多个小卷积层的堆叠替代大卷积层,这样做的优势是感受野不变,但是增加了更多非线性的因素(因为每一层卷积后都跟一个relu激活函数)。事实证明,更深的网络的确能带来更好的效果,并且观察训练结果可以发现,更深的网络更加稳定,不易抖动。
6、特征图可视化
光是看代码,是难以理解卷积神经网络是如何识别数字的。所以不如将卷积神经网络中每一层输出的特征图显示出来,便能知道在卷积神经网络这个黑盒子里,到底发生了什么。
实现特征图可视化的基本思想是:卷积神经网络处理的数据类型是tensor(张量),张量是无法用于显示图片的,所以需要将其转换成可以显示为图片的数据类型,比如numpy。再通过matplotlib,将其显示出来。
具体实现通过我定义的函数show_graph()
实现,定义在了./model/network/LeNet.py
中,你可以去掉相应的注释来显示特征图或者保存特征图。
6.1 原图
下图是测试集中一个手写数字“8”的图片:
通过观察图片和观察具体tensor的输出可以发现,MNIST数据集存放的是一通道的黑白图片,其中的像素值是0~255,其特征较为简单。
6.2 卷积后的特征图
下图是经过一次卷积之后的特征图:
为什么会有6张呢,因为第一层的卷积层输出通道数是6,所以会生成6张不同的特征图。虽然看着感觉六个特征图差别不大,那是因为数据集过于简单,如果是复杂一些的图片,便能看到其不同。图片经过卷积之后,图像像素从28 * 28变成了26 * 26。现在用肉眼还能勉强看出来是个数字8。
6.3 激活函数ReLu后的特征图
下面显示卷积->ReLu激活函数后的图像:
这一层的输出同样是6张,但是由于篇幅就不全部贴出来了。看了图像之后马上就能理解,ReLu激活函数干了什么。通俗的讲:将黑的地方变得更黑,白的地方保持不变。ReLu的表达式:
使小于0的数字等于0,大于0的数字则保持不变。(因为训练过程中,图像数据都是经过归一化处理的,使得像素值的范围为-1~1)。
6.4 最大池化后的特征图
下面显示卷积->ReLu激活函数->最大池化层的图像:
显而易见,图像经过最大池化层之后,像素缩小了一半,这也正是池化层(下采样)的作用:缩小图像尺寸。能减少网络的计算量,也能在一定程度上缓解过拟合的问题。但这只是一定程度上,并且池化层可能会过滤掉很多有用的特征。
值得注意的是,本程序用的池化层都是最大池化max_pooling,所以可以发现,相比较前一层的特征图,整体图片的亮度变得更亮了,因为最大池化是选择区域中值最大的值进行保留。
6.5 最后的特征图
下图显示的是多次卷积和激活函数和最大池化后的图片:
可以看到,经过多次卷积、激活函数和池化层之后,图像已经“面目全非”,肉眼已经完全分辨不出图片原本代表着什么数字。但是对于神经网络来说,图片永远只是一堆数字,这张图片也是神经网络计算出来的数字特征。在经过这一步之后,通过将图片展平即变为一维向量x = x.view(x.size(0), -1)
。由于是最后一层是4 * 4 * 16的输出,所以展平后就得到了长度为4 * 4 * 16 = 256的一维向量。再通过全连接层,可将一维向量变为长度为10的输出(0-9共10类)。
6.5 输出
因为进入全连接层之后,tensor已经变为了一维向量,无法以图片的形式显示,所以只能输出看具体的数字。以下为LeNet全连接层的代码:
self.fc1 = nn.Linear(16 * 4 * 4, 120)
self.fc2 = nn.Linear(120, 84)
self.fc3 = nn.Linear(84, 10)
并且输出全连接层各个位置的张量大小以及最后一层的输出:
x = x.view(x.size(0), -1)
print(x[0].size())
x = self.fc1(x)
print(x[0].size())
x = F.relu(x)
x = self.fc2(x)
print(x[0].size())
x = F.relu(x)
x = self.fc3(x)
print(x[0])
输出结果如下:
可以看到,展平后的张量长度的确为256,经过一层全连接层之后长度变为120,然后是84,最后输出最后一层的张量。可以看到,张量每个元素的值代表着该类的概率,由于该图片识别为8,所以在index为8的地方值最大,最后得出该手写体为数字“8”的结论。softmax也是根据张量计算出最后的预测值。
写博客不多,如果有错误请多多包涵!也欢迎小伙伴们留言讨论~