在上节中,我主要是讲了梯度这么一回事。讲了它是怎么样的一个东西,以及它对以后的工作会产生怎么样的一个影响。我们在日后的工作中怎么样来通过梯度来开展我们目前的工作。
MNIST数据集是一组常见的图像,常用于测评和比较机器学习算法的性能。其中6万幅图像用于训练机器学习模型,另外一万幅用于测试模型。
这些大小为28像素✖28像素的单色(monochrome)图像没有颜色。每个像素是一个0~~255的数值,表示像素的明暗度。
首先,进入到jupyter notebook 中创建一个mnist_data的新文件夹。注意这个文件夹要和其他的笔记本要放在同一个笔记本里。
通过以下链接讲mnist数据集下载到我们的计算机本地硬盘当中。
训练数据集:https://pjreddie.com/media/files/mnist_train.csv
测试数据集:https://pjreddie.com/media/files/mnist_test.csv
下载完成之后,把这两个文件上传到mnist_data文件夹中。我们可以先进入mnist_data 文件夹中,点击NEW 新建按钮,选择upload files
过几分钟之后,我们就可以在mnist_data文件夹里看到这两个文件。
首先,这里使用的mnist数据文件是csv格式的,每行由逗号分隔的值组成。有非常多方法可以加载和查看数据。这里,我们使用简单易用的pandas库。
在一个新单元格中,导入pandas库。
在下一个单元格中,我们使用pandas讲训练数据读取到一个DataFrame中。下面是一整行代码。
我们可以检查代码中使用的文件路劲是否与文件存储位置匹配。
pandas DataFrame是一个与numpy数组相似的数组结构,具有许多附加功能,包括可为列和行命名,以及提供便利函数对数据求和和过滤等。
在这里我们使用head()函数查看一个较大DataFrame的前几行。这里我们只显示数据集的前5行。
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
df = pandas.read_csv('mnist_test.csv', header=None)
df.head()
得到以下结果
0 1 2 3 4 5 6 7 8 9 ... 775 776 777 778 779 780 781 782 783 784
0 7 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
1 2 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
2 1 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
3 0 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
4 4 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
5 rows × 785 columns
mnistde 每一行数据包括785个值。第一个值是图像所表示的数字,其余的784个值是图像(尺寸为28像素×28像素)的像素值。
我们可以用info()函数来查看dataframe的概况。以下结果告诉我们dataframe有10000行。这对应有10000幅测试图像。同时,我们也可以确认每行有785个值。
df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 10000 entries, 0 to 9999
Columns: 785 entries, 0 to 784
dtypes: int64(785)
memory usage: 59.9 MB
让我们将一行像素值转换成实际图像来直观地查看一下。
我们使用通用的matplotlib库来显示图像。在下面的代码中,我们导入matplotlib的pyplot包。运行更新后的单元格,pyplot可用。
然后我们来看以下代码。
首先,从mnist数据中选取我们感兴趣的图像。第一幅图像,也就是第一行,可通过row=13选定。df.iloc[row]选择数据集的第一行并赋值给变量data。接着我们从改行中选择第一个数字,并将其命名为label,也就是标签。
然后选择该行中其余784个值,并将他们重新映射为一个28×28的正方形数组。我们将这个数组赋值给变量img,因为它是图像。接着,我们将数组绘制成位图,并在标题中显示之前提取的标签。绘制位图的imgshow()函数有很多标签选项,我们使用的两个选项分别指示pyplot无需平滑像素以及指定调色板的颜色为蓝色。
# get data from dataframe
row = 13
data = df.iloc[row]
# label is the first value
label = data[0]
# image data is the remaining 784 values
img = data[1:].values.reshape(28,28)
plt.title("label = " + str(label))
plt.imshow(img, interpolation='none', cmap='Blues')
plt.show()
现在,我们看到了mnist训练数据集中的第一幅图像。它看起来像6,标签也确实是6.
在此之前,我们先画出我们希望实现的目标。下图显示了我们的起始点和终点。
起始点是一幅mnist数据集中的图像,它的像素个数为28×28=784.这意味着我们的神经网络的第一层必须有784个节点。对于输入层的大小,我们没有太多的选择。
可以选择的是最后的输出层。它需要回答“这是什么数字”的问题。答案是“0-9”的任意一个数字,也就是10种不同的输出。最直接的解决方案是,为每一个可能的类别分配一个节点。
对于隐藏的中间层,我们有更多的选择。但是我们学习的是怎么去使用pytorch,而不是去优化隐藏层设计,所以这里的中间层大小为200.
网络中的任意一层的所有节点,都会连接到下一层中的所有节点,这种网络层也被称为全连接层。
上图缺少了一项关键的信息。我们需要为隐藏层和输出层的输出选择一个激活函数。在这里我们使用的是S型逻辑函数sigmoid。为了简单起见,我们继续用它作为激活函数。
这样,我们的一个基础的网络设计就已经构思好了。接着就是准备用pytorch来实现这个网络设计,而现在我们已经把这个网络设计构思好了,我们又应该怎么去运用pytorch来实现这个构思呢?可以说pytorch在一定的程度上简化了构建和运行神经网络的流程,所以我们需要遵守pytorch的编码规则。
当创建神经网络类时,我们需要继承pytorch的torch.nn模块。这样一来,新的神经网络就具备了许多pytorch的功能,如自动构建计算图、查看权重以及在训练期间更新权重等。
接下来,我们在一个新的笔记本中,同时导入torch和torch.nn
import torch
import torch.nn as nn
将torch.nn模块作为nn导入,是一种常见的命名方式。
接着我们开始构建神经网络类class。下面的代码展示了一个名为Classifier的类,它继承了nn.Module。
class Classifier(nn.Module):
def __init__(self):
#初始化python父类
super().__init__()
init(self)是一个特殊的函数,当我们从一个类中创建对象(object)时就需要调用它。它通常用于设置一个对象,为被调用做好准备。可能有些人听说过它的另一个名字-----构造函数(constructor)。这是一个很形象的名称。在这里,super().init()语句看似很神秘,但事实上只不过是调用了父类的构造函数。可以说,pytorch.nn模块就会为我们设置分类器;学到这里后有没觉得pytorch其实就是很简单的东西??是吧,就是这么简单。
现在我们开始来设计神经网络的结构。设计网络结构有很多种方法。对于比较简单的网络,我们可以使用nn.Sequential(),它允许我们提供一个网络模块的列表。模块必须按照我们希望的信息传递顺序添加到容器中。
class Classifier(nn.Module):
def __init__(self):
# initialise parent pytorch class
super().__init__()
# define neural network layers
self.model = nn.Sequential(
nn.Linear(784, 200),
nn.Sigmoid(),
nn.Linear(200, 10),
nn.Sigmoid()
)
然后我们继续来分析这一段代码;我们可以看到 nn.Sequential()中包括了以下几个模块:
self.loss_function = nn.MSELoss()
我们发现,“误差函数(error funtion)”和 “损失函数(loss function)”这两个词经常被互换使用,但是这通常是没有毛病的,是可以接受滴。如果希望更精确一些,“误差”单纯指预期输出和实际输出之间的差值,而“损失”是根据误差计算得到的,需要考虑具体需要解决的问题。
我们需要使用误差,更准确的说是损失,来更新我们网络的链接权重。同样的,更新权重的方法有多种,pytorch提供了函数来支持常用的几种方法。这里我们先来介绍一个简单的方法------------随机梯度下降(stochastic gradient descent,SGD),将学习率设置为0.01。
self.optimiser = torch.optim.SGD(self.parameters(), lr=0.01)
在上面的代码种,我们把所有可学习参数都传递给SGD优化器。这些参数可以通过self.parameters()访问,这也是pytorch提供的功能之一。
pytorch假定通过一个forward()方法向网络传递信息。我们需要自己创建一个forward()方法,但是它可以非常简短。
def forward(self, inputs):
return self.model(inputs)
这里,我们只将输入传递给self.model(),它由nn.Sequential()定义。模型的输出之间返回给forward()的主调函数。
然后写到这里,我们再来回顾一下我们之前的工作。
class Classifier(nn.Module):
def __init__(self):
super().__init__()
self.model = nn.Sequential(
nn.Linear(784, 200),
nn.Sigmoid(),
nn.Linear(200, 10),
nn.Sigmoid()
)
self.loss_function = nn.MSELoss()
self.optimiser = torch.optim.SGD(self.parameters(), lr=0.01)
pass
def forward(self, inputs):
return self.model(inputs)
到此为止,我们的神经网络基本就已经搭建完毕。接下,我们应该做什么呢?是的,没错。我们应该考虑怎么去训练这个网络。那我们还需要像forward()函数一样的train()函数嘛?实际上,这不是必须的。pytorch允许我们按照自己的想法构建网络的训练代码。
这时候为了代码的整洁,我们选择与forward()保持一致,创建一个train()函数。
train()既需要网络的输入值,也需要预期的目标值。这样才可以与实际输出进行比较,并计算损失值。
def train(self, inputs, targets):
outputs = self.forward(inputs)
loss = self.loss_function(outputs, targets)
train()函数首先做的,是使用forward()函数传递输入值给网络并获得输出值。
我们之前定义的损失函数再这里是用来计算损失值得。可以看出,pytorch简化了计算过程。我们只需要向该函数提供网络得输出值和预期得目标值即可。
定义好训练函数之后,下一步我们的工作是什么呢??让我们来想一下,在定义完训练函数之后我们的网络也要及时的进行更新,此时我们就是使用损失来更新网络的链接权重。我们需要为每个节点计算误差梯度,再更新链接权值。
而pytorch简化了这一个过程。
self.optimiser.zero_grad()
loss.backward()
self.optimiser.step()
这三个步骤算得上是所有pytorch神经网络的精髓所在。再下面我们来具体的说明这三个东西。
在上一篇文章中我们以及详细的介绍了backward(),并且用它来计算了一个简单网络的梯度。在这里backward()函数的用法也是一样的。我们可以把计算图的最终节点看作损失函数。该函数对每个进入损失的节点计算梯度。这些梯度是损失随着每个可学习参数的变化。
优化器利用这些梯度,逐步(step)沿着梯度更新可学习参数。
工作到目前为止就基本完成了,然后我们终于有了一个可以训练的网络。在训练它之前,我们先添加一种方法,以便观察训练效果的好坏。
当训练神经网络时,我们并没有办法看到训练的进展。我们能在训练后评估网络的效果,但是没有办法知道训练进展的是否顺利,也没有办法知道是否应该继续训练。这就会在一定程度上给我们造成了困扰。
跟踪训练的一种方法时监控损失,在train()中 ,我们在每次计算损失值时,将副本保存到一个列表里。这以为着该表会变得非常大,因为训练神经网络通常会运行成千上万、甚至是百万个样本。MNIST数据集有60000个训练样本,而且我们可能需要运行好几个周期(epoch)。现在有一种更好的办法,就是在每完成10轮训练样本后保留一份损失副本。这就需要我们记录train()的运行频率。
以下的代码在神经网络类的构造函数中创建一个初始值为0的计数值(counter)以及一个名为progress的空列表。
self.counter = 0
self.progress = []
在train()函数中,我们可以每隔10个训练样本增加一次计算器的值,并将损失值添加进列表的末尾。
/self.counter += 1
if (self.counter % 10 == 0):
self.progress.append(loss.item())
pass
在上面的代码中,%10表示除以10之后的余数,当计数器为了10、20、30等时,余数为0.这里使用的item()函数只是为了方便开展一个单值张量,获取里面的数字。
我们可以在每10000次训练后打印计数器的值,这样就可以了解训练进展的快慢。
if (self.counter % 10000 == 0):
print("counter = ", self.counter)
pass
接着就是要将损失值绘制成图,我们可以在神经网络类中添加一个新函数plot_progress()
def plot_progress(self):
df = pandas.DataFrame(self.progress, columns=['loss'])
df.plot(ylim=(0, 1.0), figsize=(16,8), alpha=0.1, marker='.', grid=True, yticks=(0, 0.25, 0.5))
pass
这段代码看起来很复杂,但其实只有两行。第一行是将损失值列表progress转换为一个pandas DataFrame ,这样方便我们绘制图。第二行是使用plot()函数的选项,调整图的设计和风格。
这样,我们离开始训练又更近了一步。
在此之前,我们已经将一个csv文件中的MNIST数据加载到pandas DataFrame中。我们完全可以继续从DataFrame中读取数据。然而,为了学习pytorch,我们应该尝试以pytorch的方式加载和使用数据。
pytorch使用torch.ytils.data.DataLoader实现了一些实用的功能,比如自动打乱数据顺序、多个进程并行加载、分批处理等,需要先将数据在入一个torch.ytils.data.Dataset对象。
为了简单起见,我们暂时不需要打乱数据顺序或分批处理。但是,我们仍会使用torch.ytils.data.Datase类,以积累pytorch的经验。
通过以下代码导入pytorch的torch.ytils.data.Datase类。
from torch.utils.data import Dataset
当我们从nn.Module继承一个神经网络类时,需要定义forward()函数。同样的,对于继承自Dataset的数据集,我们需要提供以下两个特殊的函数。
class MnistDataset(Dataset):
def __init__(self, csv_file):
self.data_df = pandas.read_csv(csv_file, header=None)
pass
def __len__(self):
return len(self.data_df)
def __getitem__(self, index):
# image target (label)
label = self.data_df.iloc[index,0]
target = torch.zeros((10))
target[label] = 1.0
# image data, normalised from 0-255 to 0-1
image_values = torch.FloatTensor(self.data_df.iloc[index,1:].values) / 255.0
# return label, image data tensor and target tensor
return label, image_values, target
pass
首先,在创建该类的一个对象时,csv_file被读入一个名为data_df的pandas DataFrame。
len() 函数的作用是返回DataFrame的大小。这很简单!
getitem()函数则比较有趣。就像我们之前对MNIST数据所做的实验一样,我们从数据集中的第index项中提取一个标签(label)。
接着,我们创建了一个维度为10的张量变量target来表示神经网络的预期输出。除了与标签相对应的项是1之外,其他值皆为0。比如,标签0所对应的张量是[1, 0, 0, 0, 0, 0, 0, 0, 0, 0],而标签4所对应的张量是[0, 0, 0, 0, 1, 0, 0, 0, 0, 0]。这种表示方法叫独热编码 (one-hot encoding)。然后,我们以像素值创建一个张量变量image_values。所有像素值都被除以255,结果值的范围是0~1。
最后,getitem() 返回label、image_values和target 3个值。
即使PyTorch不需要,我们也可以为MnistDataset类添加一个制图方法,以方便查看我们正在处理的数据。为此,我们需要跟之前一样,导入matplotlib.pyplot库。
def plot_image(self, index):
img = self.data_df.iloc[index,1:].values.reshape(28,28)
plt.title("label = " + str(self.data_df.iloc[index,0]))
plt.imshow(img, interpolation='none', cmap='Blues')
pass
让我们检查一下到目前为止是否一切都正常。首先,我们从类中创建一个数据集对象,并将csv文件为止传递给它。
mnist_dataset = MnistDataset('mnist_train.csv')
我们知道类构造函数将CSV文件中的数据加载到pandas DataFrame中。让我们使用plot_image()函数绘制数据集中的第10幅图像。第10幅图像的索引是9,因为第一幅的索引是0。
mnist_dataset.plot_image(9)
此时,我们应该看到一个手写数字图像“4”.标签也告诉我们它应该是“4”
这表示我们的数据集类可以正确的加载数据了。
接着,检查mnist_dataset 是否允许我们通过索引访问,例如mnist_dataset[100]。我们应该看到它返回标签、像素值和目标张量。
现在,训练一个分类器神经网络非常简单。因为我们已经完成了复杂的工作,包括定义数据集类和神经网络类。
首先我们从Classifier类创建一个神经网络。``
C = Classifier()
训练网络的代码同样很简单:
for label, image_data_tensor, target_tensor in mnist_dataset:
C.train(image_data_tensor, target_tensor)
pass
由于mnist_dataset继承了PyTorch Dataset,它允许我们使用for循环遍历所有训练数据。对于每个样本,我们只将图像数据和目标张量传递给分类器的train()方法。多次使用整个数据集来训练我们的神经网络是很有帮助的。我们可以通过在训练循环周围添加一个外部周期循环来多次使用整个数据集。
最后,记录一个Python笔记本单元格运行所需时间也很简单。我们只需要在要计时的单元格顶部添加%%time。在做神经网络实验时,它非常实用。因为它决定了在训练网络的过程中,我们有很多空闲去做别的事情。
我们的单元格时这样的:
%%time
# create neural network
C = Classifier()
epochs = 4
for i in range(epochs):
print('training epoch', i+1, "of", epochs)
for label, image_data_tensor, target_tensor in mnist_dataset:
C.train(image_data_tensor, target_tensor)
pass
pass
运行单元格需要一些时间。在每调用10000次之后,train()会打印一次更新,显示它处理了多少个样本。
我们可以看到,训练4个周期用了6min10s。考虑到每个周期有60000个训练样本,所以可以认为这个速度是相当不错的。
让我们绘制收集到的损失值,以了解我们的这个训练进展。
C.plot_progress()
我们应该会看到一幅与下图类似但又不完全一样的图。因为训练神经网络本质上是一个随机的过程。这就在一定程度上决定了我们的图像会不完全一样。
从上图可见,损失值从一开始迅速下降到大约0.1,并在训练过程中越来越慢地接近0.同时,噪声也会变得越来越多。
损失值得下降意味着网络分类图像得能力越来越好。
损失图真的很实用,它让我们了解到网络训练是否有效。它也告诉我们训练是平稳得,还是不稳定得、混乱得。
现在我们有了一个训练后得网络,可以进行图像分类了。我们将切换到包含10000幅图像得MNIST测试数据集。这些事我们得神经网络从来没有看到过的图像。
让我们用一个新的dataset对象加载数据集。
mnist_test_dataset = MnistDataset('mnist_test.csv')
我们可以从测试数据集中挑选一幅图像来查看。下面的代码选择了索引19(record=19)的第20幅图像。
record = 19
mnist_test_dataset.plot_image(record)
这副图像看起来像“4”.从记录中提取的标签也证实它是“4”.
让我们看看训练过的神经网络是如何判断这幅图像的。下面的代码继续使用第20幅图像并提取像素值作为image_data。我们使用forward()函数将图像传递并通过神经网络。
image_data = mnist_test_dataset[record][1]
output = C.forward(image_data)
pandas.DataFrame(output.detach().numpy()).plot(kind='bar', legend=False, ylim=(0,1))
输出被转换成一个简单的numpy数组,再被包装成一个dataframe,以便绘制柱形图。
10条柱形分别对应10个神经网络输出节点的值。最大值对应节点4,也就是说我们的网络认为图像是4。
现在看到了吧, 网络对测试图像的分类是正确的,真是令人兴奋呀。
更进一步地观察会发现,所有其他节点的输出都不是0。我们不能指望神经网络能够输出明确的答案。事实上,这幅图像看起来也像9,但是与4的相似度更高。回头再看实际的图像,我们可以看到9和4有时候的确不容易区分。
然后我们可以选另一幅图像试一试。比如,第43幅图像是一个很好的模糊图像例子。此外,看看是否能找到让网络出错的图像。我所训练的网络错误地分类第34幅图像。查看这幅图像会发现,它的确写得特别潦草。
要知道我们的神经网络对图像分类的表现如何,一种直接的方法是对MNIST测试数据集中所有10 000幅图像进行分类,并记录正确分类的样本数。分类是否正确可以通过比较网络输出和图像的标签来分辨。
在以下代码中,分数score的初始值为0。接着遍历测试数据,并在每次网络输出与标签匹配时加分。
score = 0
items = 0
for label, image_data_tensor, target_tensor in mnist_test_dataset:
answer = C.forward(image_data_tensor).detach().numpy()
if (answer.argmax() == label):
score += 1
pass
items += 1
pass
print(score, items, score/items)
answer.argmax()语句的作用是输出张量answer中最大值的索引。如果第一个值是最大的,则argmax是0。这是回答“哪个节点的值最大”的一种推荐方法。
下面打印最后得分以及神经网络答对的样本占总样本的分数。
从上图中可以看到,模型的最后分数约为88%。考虑到这是一个简单的网络,这个分数还不算太差。
试一试,是否可以通过训练网络3个周期以上来提高得分。如果训练少于3个周期,得分又会怎么样?