使用MNIST数据集对0到9之间的数字进行手写数字识别是神经网络的一个典型入门教程。
该技术在现实场景中是很有用的,比如可以把该技术用来扫描银行转帐单或支票,其中帐号和需要转账的金额可以被识别处理并写在明确定义的方框中。
在本教程中,我们将介绍如何使用Julia编程语言和名为Flux的机器学习库来实现这一技术。
为什么使用Flux和Julia?
本教程为什么想使用Flux(https://fluxml.ai/) 和Julia(https://julialang.org/) ,而不是像Torch、PyTorch、Keras或TensorFlow 2.0这样的知名框架呢?
一个很好的原因是因为Flux更易于学习,而且它提供更好的性能和拥有有更大的潜力,另外一个原因是,Flux在仍然是一个小库的情况下实现了很多功能。Flux库非常小,因为它所做的大部分工作都是由Julia编程语言本身提供的。
例如,如果你查看Gorgonia ML库(https://github.com/gorgonia/gorgonia) 中的Go编程语言,你将看到,它明确地展示了其他机器学习库如何构建一个需要执行和区分的表达式图。在Flux中,这个图就是Julia本身。Julia与LISP非常相似,因为Julia代码可以很容易地表示为数据结构,可以对其进行修改和计算。
机器学习概论
如果你是机器学习的新手,你可以跟着本教程来学习,但并不是所有的东西对你来说都是有价值的。你也可以看看我以前关于Medium的一些文章,它们可能会解释你一些新手的疑惑:
- 线性代数的核心思想。(https://medium.com/@Jernfrost/the-core-idea-of-linear-algebra-7405863d8c1d)
- 线性代数基本上是关于向量和矩阵的,这是你在机器学习中经常用到的东西。
- 使用引用。(https://medium.com/@Jernfrost/working-with-and-emulating-references-in-julia-e02c1cae5826)
- 它看起来有点不太好理解,但是如果你想理解像Flux这样的ML库,那么理解Julia中的引用是很重要的。
- Flux的实现。(https://medium.com/@Jernfrost/implementation-of-a-modern-machine-learning-library-3596badf3be)
- 如何实现Flux-ML库的初学者指南。
- 机器学习简介。(https://medium.com/@Jernfrost/machine-learning-for-dummies-in-julia-6cd4d2e71a46) 机器学习概论。
简单多层感知机
我们要编程的人工神经网络被称为简单的多层感知机,这是神经网络(ANN)的基础,大多数教科书都会从它开始。
我先展示整个程序,然后我们再更详细地讲解不同的部分。
using Flux, Flux.Data.MNIST, Statistics
using Flux: onehotbatch, onecold, crossentropy, throttle
using Base.Iterators: repeated
# Load training data. 28x28 grayscale images of digits
imgs = MNIST.images()
# Reorder the layout of the data for the ANN
imagestrip(image::Matrix{<:Gray}) = Float32.(reshape(image, :))
X = hcat(imagestrip.(imgs)...)
# Target output. What digit each image represents.
labels = MNIST.labels()
Y = onehotbatch(labels, 0:9)
# Defining the model (a neural network)
m = Chain(
Dense(28*28, 32, relu),
Dense(32, 10),
softmax)
loss(x, y) = crossentropy(m(x), y)
dataset = repeated((X, Y), 200)
opt = ADAM()
evalcb = () -> @show(loss(X, Y))
# Perform training on data
Flux.train!(loss, params(m), dataset, opt, cb = throttle(evalcb, 10))
探索输入数据
数据预处理通常是数据科学中最大的工作之一。通常情况下,数据的组织或格式化方式与将其输入算法所需的方式不同。
我们首先将MNIST数据集加载为60000个28x28像素的灰度图像:
imgs = MNIST.images()
现在,如果你这样处理数据,你可能不知道输出的数据是怎么样子的,但使用Julia研究,我们只需检查一下:
julia> size(imgs)
(60000,)
输出说明了imgs是一个包含60000个元素的一维数组。但这些元素是什么?
julia> eltype(imgs)
Array{Gray{FixedPointNumbers.Normed{UInt8,8}},2}
你可能看不懂,但我可以简单地告诉你这是什么:
julia> eltype(imgs) <: Matrix{T} where T <: Gray
true
这告诉我们imgs中的每个元素都是某种值矩阵,这些值属于某种类型T,它是Gray类型的子类型。什么是Gray类型?
我们可以在Julia在线文档中查找:
help?> Gray
Gray is a grayscale object. You can extract its value with gray(c).
如果我们想知道这些灰度值矩阵的维数,则可以:
julia> size(imgs[1])
(28, 28)
julia> size(imgs[2])
(28, 28)
这告诉我们它们的尺寸为28x28像素。我们可以通过简单地绘制其中的一些图来进一步验证这一点。Julia的Plots库使你可以绘制函数和图像。
julia> using Plots
julia> plot(imgs[2])
得出了下面的图像,显然看起来像一个数字:
但是,你可能会发现了解更多的数据看起来是更有用。我们可以很容易地一起绘制几个图像:
imgplots = plot.(imgs[1:9])
plot(imgplots...)
现在我们知道了数据是什么样的了。
准备输入数据
然而,我们不能像这样将数据输入到我们的神经网络(ANN),因为每个神经网络输入必须是列向量,而不是矩阵。
这是因为神经网络期望一个矩阵作为输入,矩阵中的每一列都是输入。ANN所看到的三乘十矩阵对应于十个不同的输入,其中每个输入包含三个不同的值或者更具体地说是三个不同的特征,因此,我们将28x28灰度图像转换为28x28=784的长像素带。
其次,我们的神经网络并不知道什么是灰度值,它是对浮点数据进行操作的,所以我们必须同时转换数据的维度和元素类型。
数组中的列和行数称为其形状。很多人提到了张量,虽然它并不完全精确,但它是一个涵盖了标量、向量、矩阵、立方体或任何等级的数组(基本上是数组的所有维度)的概念。
在Julia中,我们可以使用reshape函数来改变数组的形状。下面是一些你如何使用它的例子。
这将创建一个包含四个元素的列向量A:
julia> A = collect(1:4)
4-element Array{Int64,1}:
1
2
3
4
通过reshape我们把它变成一个二乘二的矩阵B:
julia> B = reshape(A, (2, 2))
2×2 Array{Int64,2}:
1 3
2 4
矩阵可以再次转换为列向量:
julia> reshape(B, 4)
4-element Array{Int64,1}:
1
2
3
4
找出一个列向量到底有多少个元素是不切实际的,你可以让Julia只通过写来计算合适的长度。
julia> reshape(B, :)
4-element Array{Int64,1}:
1
2
3
4
有了这些信息,应该更容易看到imagestrip函数的实际功能了,它将28x28的灰度矩阵转换为784个32位浮点值的列向量。
imagestrip(image::Matrix{<:Gray}) = Float32.(reshape(image, :))
该.符号用于将函数应用于数组的每个元素,因此Float32.(xs)与map(Float32, xs)是相同的。
接下来,我们将imagestrip函数应用于6万张灰度图像中的每一张,生成784x6000个输入矩阵X。
X = hcat(imagestrip.(imgs)...)
这是如何运作的?可以想象为imagestrip.(imgs)将图像转换为单个输入值的数组,例如[X₁, X₂, X₃, ..., Xₙ],其中n = 60,000,每个Xᵢ都是784个浮点值。
使用splat运算符...,我们将其转换为所有这些列向量的水平连接,以产生模型输入。
X = hcat(X₁, X₂, X₃, ..., Xₙ)
如果要验证尺寸,则可以运行size(X)。接下来,我们加载标签。
labels = MNIST.labels()
标签是我们称之为监督学习中观察的"答案"部分。在我们的任务中,标签是从0到9的数字。手绘数字的每一个图像都应归类为十个不同的数字之一,例如,如果这是一个包含不同花卉品种的花瓣长度和花瓣宽度的虹膜数据集,那么该品种的名称就是标签。
Xᵢ代表我们所有的特征向量,用机器学习的术语来说,每个像素的灰度值都是一个特征。
你可以将标签与我们绘制的图像进行比较。
imgplots = plot.(imgs[1:9])
plot(imgplots...)
labels[1:9]
独热编码
每个图像一个标签,则有60000个标签,然而神经网络不能直接输出标签。例如,如果你正试图对猫和狗的图像进行分类,那么一个网络不能输出字符串“dog”或“cat”,因为它是使用浮点值的。
如果标签是一个不一定有用的数字,例如如果输出是一系列邮政编码,那么将3000的邮政编码视为1500的邮政编码的两倍是没有意义的,同样,当使用神经网络从图像中预测数字时,4的大小是2的两倍并不重要,数字也可能是字母,因此它们的值不重要。
我们在机器学习中处理这个问题的方法是使用所谓的独热编码,这意味着,如果我们有标签A、B和C,并且我们想用独热编码来表示它们,那么A是[1、0、0],B是[0、1、0],C是[0、0、1]。
这看起来很浪费空间,但在Julia one hot数组内部,它只跟踪元素的索引,并不保存所有的零。
下面是一些正在使用的编码示例:
julia> Flux.onehot('B', ['A', 'B', 'C'])
3-element Flux.OneHotVector:
0
1
0
julia> Flux.onehot("foo", ["foo", "bar", "baz"])
3-element Flux.OneHotVector:
1
0
0
但是,我们不会使用onehot函数,因为我们正在创建一批独热编码标签,我们将把60000张图片作为一个批次来处理。
机器学习的批次指的是在我们模型(神经网络)的权值或参数更新之前必须完成的最小样本数量。
Y = onehotbatch(labels, 0:9)
这将创建目标输出。在理想情况下,模型(X)==Y,但在现实中,即使经过模型的训练,也会有一些偏差。
我们已经讨论完数据准备,现在让我们用人工神经网络来构造我们的模型。
构造神经网络模型
模型是真实世界的简化表示,就像我们可以建立简化的物理模型一样,我们也可以用数学或代码来创建物理世界的模型,现实中存在许多这样的数学模型。
例如,统计模型可以使用统计数据来模拟人们一天中是如何到达商店的。一般来说,人们会以一种遵循特定概率分布的方式到达。
在我们的例子中,我们试图用神经网络来模拟现实世界中的一些东西,当然,这只是对现实世界的一种近似。
当我们建立一个神经网络时,我们有很多可以玩的东西。网络是由多个层连接而成的,每一层通常都有一个激活函数。
建立一个神经网络的挑战是选择合适的层和激活函数,并决定每层应该有多少个节点。
我们的模型非常简单,定义如下:
m = Chain(
Dense(28^2, 32, relu),
Dense(32, 10),
softmax)
这是一个三层的神经网络。Chain用于将各个层连接在一起。第一层Dense(28^2, 32, relu)有784(28x28)个输入节点,对应于每个图像中的像素数。
它使用校正线性单元(ReLU)函数作为激活函数。在经典的神经网络文献中,通常会介绍sigmoid和tanh。relu等激活函数,这些激活函数在大多数情况下都工作得很好,包括图像的分类。
下一层是我们的隐藏层,它接受32个输入,因为前一层有32个输出,隐藏节点的数量没有明确的对错选择。
但输出的数量根据不同任务是不一样的,因为我们希望每个数字有一个输出,这也就是“独热编码”发挥作用的地方。
Softmax函数
最后一层,是softmax函数,它以前一层的输出的矩阵作为输入,并沿着每一列进行归一化。
标准化将60000列中的每一列转换为概率分布。那到底是什么意思?
概率是0到1之间的值,0表示事件永远不会发生,1是肯定会发生。
与min-max归一化一样,softmax将所有输入归一化为0到1之间的值,但是与min max不同的是它会确保所有值的和为一。这需要一些例子来说明。
假设我创建了10个从1到10的随机值,我们可以放任意范围和任意数量的值。
julia> ys = rand(1:10, 10)
10-element Array{Int64,1}:
9
6
10
5
10
2
6
6
7
9
现在让我们使用不同的归一化函数归一化这个数组,我们将使用来自LinearAlgebra模块的normalize,因为它与Julia捆绑在一起。
但首先使用softmax:
julia> softmax(ys)
10-element Array{Float64,1}:
0.12919082661651196
0.006432032517257137
0.3511770763952676
0.002366212528045101
0.3511770763952676
0.00011780678490667763
0.006432032517257137
0.006432032517257137
0.017484077111717768
0.12919082661651196
如你所见,所有值都在0到1之间。现在看一下如果我们把它们加起来会发生什么:
julia> sum(softmax(ys))
0.9999999999999999
它们基本上变成了1。现在将其与normalize的功能进行对比:
julia> using LinearAlgebra
julia> normalize(ys)
10-element Array{Float64,1}:
0.38446094597254243
0.25630729731502827
0.4271788288583805
0.21358941442919024
0.4271788288583805
0.0854357657716761
0.25630729731502827
0.25630729731502827
0.2990251802008663
0.38446094597254243
julia> sum(normalize(ys))
2.9902518020086633
julia> norm(normalize(ys))
1.0
julia> norm(softmax(ys))
0.52959100847191
如果对用normalize归一化的值求和,它们只会得到一些随机值,然而如果我们把结果反馈给norm,我们得到的结果正好是1.0。
不同之处在于,normalize将向量中的值进行了归一化,以便它们可以表示单位向量,即长度正好为一的向量。norm给出向量的大小。
相比之下,softmax不会将这些值视为向量,而是将其视为概率分布,每个元素表示输入图像为该数字的概率。
假设我们有A,B和C的图像作为输入,如果你从softmax得到一个输出值是[0.1,0.7,0.2],那么输入图像有10%的可能性是A的图形,有70%的可能性是B的图形,最后有20%的可能性是C的图形。
这就是为什么我们希望softmax作为最后一层的原因。用神经网络不能绝对确定输入图像是什么,但是我们可以给出一个概率分布,它表示更有可能是哪个数字。
定义损失函数
当训练我们的神经网络(模型)给出准确的预测时,我们需要定义人工神经网络(ANN)的评估指标。
为此,我们使用所谓的损失函数。损失函数有很多名字,20年前当我被教授神经网络时,我们曾称之为误差函数,也有人称之为成本函数。
然而,归根结底,这是一种表达我们的预测与现实相比有多正确的方式。
loss(x, y) = crossentropy(m(x), y)
训练神经网络实际上是最小化这个函数的输出,所以这是一个优化问题。训练是一个反复调整模型中参数(权重)的过程,直到损失函数的输出变低,或者换句话说,直到我们的预测误差变低。
均方误差函数(MSE)是计算预测错误程度的经典方法,这就意味着取差的平方,然而,MSE更适合于线性回归(将一条或多条直线拟合到某些观测值)。
在这种情况下,我们改用交叉熵函数。当你的最后一层是softmax,进行分类而不是线性回归时,这是我比较推荐的选择。
指定Epoch
在机器学习术语中,Epoch是训练算法进行一次完整的迭代,换句话说:一个Epoch处理一个批次并更新权重
因此,如果我们使用10个Epoch来进行训练,那么模型的参数/权重将更新/调整10次。
为了得到200个Epoch,我们使用repeat重复我们的批处理200次。它实际上不会重复我们的数据200次,它只是用迭代器创建了这样的错觉。
dataset = repeated((X, Y), 200)
在数据集中,我们得到的数组如下:
dataset = [(X1, Y1), (X2, Y2), ..., (X200, Y200)]
优化器
最常见和最著名的训练神经网络策略是梯度下降算法,这是由Julia中的Descent类型提供的。
然而,在我们的例子中,当我们处理大量带有相当数量噪声的数据时,建议改用ADAM优化器,这就是所谓的随机优化。
opt = ADAM()
进行训练
我们终于可以进行训练了,但我们希望在训练进行的过程中得到一些反馈。我们定义了一个回调函数,在每次迭代(epoch)时,它将输出loss函数的值,从而显示错误。我们希望每次迭代时都能看到这个错误。
evalcb = () -> @show(loss(X, Y))
观察错误发展的一个有用的地方是,你可以看到是否有振荡。人工神经网络过快地朝着最低值过渡,会导致它朝相反的方向移动,如果速度太快,则会向相反的方向超调,振荡会变得更加剧烈,直到误差变为无穷大。
这是一个切换优化算法或降低学习率的提示。
不管怎样,这就是你训练的方式。注意,回调是可选的:
Flux.train!(loss, params(m), dataset, opt, cb = throttle(evalcb, 10))
评价模型预测精度
经过训练后,我们可以测试模型在预测方面的表现。
我们定义了这样一个函数:
accuracy(x, y) = mean(onecold((m(x))) .== onecold(y))
然后我们用输入数据和标签作为输入参数来调用它:
@show accuracy(X, Y)
至于什么是onecold?在某种程度上,它与onehot实现的效果是相反的。
我们的输出m(X)都是概率分布,而我们的目标Y都是独热向量。
它们不能直接比较,所以我们需要使用onecold来做一个转换。给定概率分布,它选择最可能的候选:
julia> onecold([0.1, 0.7, 0.2])
2
julia> onecold([0.9, 0.05, 0.05])
1
因此,使用onecold(m(X))我们可以得到预测的标签,这可以与实际的标签onecold(y)进行比较。
用测试数据验证模型
到目前为止,我们只根据我们使用的训练数据来验证了我们的模型,然而,如果该模型不适用于新的数据,它将是完全无用的。
因此,在训练网络时,我们通常将数据分为训练数据和测试数据。测试数据不是训练的一部分,只有在训练完成后才能进行测试。
tX = hcat(float.(reshape.(MNIST.images(:test), :))...)
tY = onehotbatch(MNIST.labels(:test), 0:9)
@show accuracy(tX, tY)
最后
我希望这能帮助你理解建立神经网络的过程。
太多的教程倾向于跳过向初学者解释的内容,从而所有的新概念都会很快变得令人困惑。我希望这为初学者在进一步探索机器学习之前提供了一个起点,特别是基于Julia的机器学习,因为我认为Julia有着光明的未来。
参考链接:https://medium.com/better-programming/handwriting-recognition-using-an-artificial-neural-network-78060d2a7963
来源:oschina
链接:https://my.oschina.net/u/4337936/blog/4881472