初识人工神经网络(2)——代码实现
前一篇 文章 我们介绍了人工神经网络的基本原理,本文我们将使用 Python 来实现一个简易的神经网络,可用于识别手写数字,从而加深对于神经网络的理解。
本文实现的完整代码 传送门。
神经网络实现
根据我们的理解,神经网络应该至少包含三个部分:
- 初始化:初始化输入层节点、隐藏层节点、输出层节点的数量。
- 训练:通过特定的训练样本,优化连接权重。
- 查询:给定输入,计算输出。
对此,我们定义一个 NeuralNetwork
类来表示神经网络,其内部包含三个函数分别对应初始化、训练、查询,如下所示。
1 | # neural network class definition |
接下来,我们依次来实现神经网络的各个部分。
初始化网络
网络结构
首先,我们定义神经网络的基本结构,其包含三个层:输入层、隐藏层、输出层。此外,我们还需要定义学习率,代码如下所示。
1 | # initialise the neural network |
连接权重
其次,定义神经网络的核心参数——连接权重。这里涉及两部分连接权重,分别是:
- 输入层与隐藏层之间的连接权重矩阵:,其大小为 。
- 隐藏层与输出层之间的连接权重矩阵:,其大小为 。
在 Python 中,我们使用经典的数学库 numpy
来实现矩阵的表示和运算,通过如下的方式我们可以定义一个 的数组,数组元素是 0 ~ 1 的随机值。
1 | numpy.random.rand(rows, columns) |
在前一篇文章中,我们介绍过权重的范围在 之间。这里我们可以通过上述方式生成随机数之后再减去 0.5,从而使数组元素变成 之间的随机值,如下所示。
1 | self.wih = (numpy.random.rand(self.hnodes, self.inodes) - 0.5) |
我们之前还提到过一种优化的权重初始化方案:在一个节点传入连接数量平方根倒数的范围内进行正态分布采样,即权重范围是 ,相关代码实现如下所示。其中 numpy.random.normal()
函数用于实现正态分布采样,传入的三个参数分别是:正态分布值的中心、标准方差、数组大小。pow(self.hnodes, -0.5)
相当于节点数量的 -0.5 次方。
1 | self.wih = numpy.random.normal( 0.0 , pow(self.hnodes, -0.5) , (self.hnodes, self.inodes) ) |
最后我们结合网络结构和连接权重,可以得到完整的初始化代码,如下所示。
1 | # initialise the neural network |
查询网络
查询网络本质上就是信号转换的过程。我们知道每一个神经元都会对输入信号进行两次处理,分别是求和函数和激活函数,对应的矩阵表达式如下所示。
\begin{aligned}
X_{hidden} = & W_{input_hidden} \cdot I
\
\
O_{hidden} = & sigmoid(X_{hidden})
\end{aligned}
对于求和函数,我们可以通过 numpy 库中的矩阵点乘函数来实现,如下所示。
1 | hidden_inputs = numpy.dot(self.wih, inputs) |
对于激活函数,我们使用 激活函数,它的表达式是 。对此,SciPy Python 库中的 expit()
函数实现了 函数。我们可以在初始化方法中加入一下这段代码。
1 | self.activation_function = lambda x: scipy.special.expit(x) |
然后,我们将上述求和函数结果输入至激活函数中,即可得到隐藏层的输出,如下所示。
1 | hidden_outputs = self.activation_function(hidden_inputs) |
输出层的信号转换本质上与隐藏层一样,因此我们可以添加两行类似的代码来对输出层进行处理。
1 | final_inputs = numpy.dot(self.who, hidden_outputs) |
最后我们得到完整的查询网络代码如下所示。
1 | # query the neural network |
训练网络
训练网络主要包含两部分:
- 正向的信号转换
- 反向的权重更新
信号转换
信号转换的过程与上述查询网络一致,因此我们可以直接照搬相关代码,如下所示。
1 | def train(self, inputs_list, targets_list): |
权重更新
对于一个三层神经网络,我们只需要更新两部分权重,分别是:
- 隐藏层与输出层的权重更新
- 输入层与隐藏层的权重更新
权重更新是基于误差实现的,因此我们首先要计算误差,对于输出层的误差,计算样本的预期目标输出值与实际计算输出值的差即可;对于隐藏层的误差,我们在上一篇文章中进行了公式推导,如下所示。
\begin{aligned}
error_{hidden} = W^T_{hidden_output} \cdot error_{output}
\end{aligned}
由此,我们可以得到如下所示的误差计算代码。
1 | # error is the (target - actual) |
然后,我们根据上一篇文章中推导的权重更新公式来实现相关代码,其公式如下所示。其中 是学习率。基于此,我们可以分别实现隐藏层与输出层的权重更新、输入层与隐藏层的权重更新。
1 | # update the weights for the links between the hidden and output layers |
最后,我们可以得到完整的训练网络的相关代码,如下所示。
1 | def train(self, inputs_list, targets_list): |
神经网络测试
神经网络适合用于解决不具有固定模式或计算步骤的问题,比如:图像识别、语音识别等。这里我们尝试让神经网络解决一个类似的问题——手写数字识别。下图所示是一个手写数字的示例,我们可能会对于这个数字是 4 还是 9 产生分歧。此时,神经网络就可以作为判断的辅助工具,我们的目的也是如此。
训练数据
经典的手写数字数据库 MNIST 为我们提供了训练和测试的样本数据,由于 MNIST 数据库的格式不易使用,我们使用别人构建的相对简单的 CSV 文件:
- 训练集 http://www.pjreddie.com/media/files/mnist_train.csv
- 测试集 http://www.pjreddie.com/media/files/mnist_test.csv
mnist_train.csv
和 mnist_test.csv
中的每一行代表一个样本数据,每个样本数据由 785 个数字组成,由逗号进行分隔。其中第 1 个数字是样本的预期目标值;其余 784 个数字素是样本的输入数据,本质上是一个打平的 28 x 28 的二维矩阵,每个数字表示一个颜色值,范围在 之间。如下所示,是一个样本数据的示例。
1 | 5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,18,18,18,126,136,175,26,166,255,247,127,0,0,0,0,0,0,0,0,0,0,0,0,30,36,94,154,170,253,253,253,253,253,225,172,253,242,195,64,0,0,0,0,0,0,0,0,0,0,0,49,238,253,253,253,253,253,253,253,253,251,93,82,82,56,39,0,0,0,0,0,0,0,0,0,0,0,0,18,219,253,253,253,253,253,198,182,247,241,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,80,156,107,253,253,205,11,0,43,154,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,14,1,154,253,90,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,139,253,190,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,11,190,253,70,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,35,241,225,160,108,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,81,240,253,253,119,25,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,45,186,253,253,150,27,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,16,93,252,253,187,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,249,253,249,64,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,46,130,183,253,253,207,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,39,148,229,253,253,253,250,182,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,24,114,221,253,253,253,253,201,78,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,23,66,213,253,253,253,253,198,81,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,18,171,219,253,253,253,253,195,80,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,55,172,226,253,253,253,253,244,133,11,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,136,253,253,253,212,135,132,16,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 |
将此样本数据转换成图像,可以得到如下所示的手写数字图像,通过肉眼判断手写数字与预期目标值 5 是相符合的。
构建网络
对于输入层节点,由于样本的输入数据是 28 x 28 = 784,因此在初始化网络时,传入的 input_nodes
也应该是 784。
对于输出层节点,由于神经网络是对图像进行分类,在此情况下,分类的结果应该是 [0, 9] 共 10 个数字中的任意一个,因此在初始化网络时,传入的 output_nodes
也应该是 10。
对于隐藏层节点,这也是一个可自由调节的参数。由于隐藏层是为了提取输入数据的特征和模式,理论上可以比输入更简洁,因此我们可以考虑使用一个比输入节点数更小的数来表示,比如:100。同样,学习率也是一个可自由调节的参数,这里我们暂停为 0.3。
如下所示,是网络构建的相关代码实现。
1 | # number of input, hidden and output nodes |
样本数据预处理
在上一篇文章中我们提到输入值的范围应该是 ,而当前的样本输入值的范围是 。因此我们需要进行样本数据处理,可以考虑将其缩小至 的范围之中,相关的处理代码如下所示。
1 | inputs = (numpy.asfarray(all_values[1:]) / 255.0 * 0.99) + 0.01 |
除了样本输出值,我们还需要处理样本目标值。当前输出层具有 10 个节点,输出值最大的节点的索引就是手写数字的识别结果,如下所示。对此,理想情况是定义一个数组,只有一个元素为 1,其余元素为 0。然而激活函数无法输出 0 和 1,此时如果将目标值设为 0 或 1,将会导致神经网络反馈过大的权重。因此我们要对这些目标值进行微调,使用 0.01 和 0.99 来代替 0 和 1,实现代码如下所示。
1 | #output nodes is 10 (example) |
如下所示是输入数据预处理的相关代码实现。我们首先打开样本数据的 csv
文件,然后循环依次遍历每一个样本数据,拆分成目标值和输入值,最后对它们进行预处理,从而匹配神经网络的值的约束。
1 | # load the mnist training data CSV file into a list |
训练与测试
我们使用 mnist_train.csv
完成了训练之后,可以使用 mnist_test.csv
来进行测试。我们只要再读取测试数据,输入至已训练的神经网络中查询结果,并比对输出值与目标值。为了有一个衡量标准,我们对每次样本测试进行打分,查询结果正确 +1,最后计算正确率,相关代码实现如下所示。
1 | # load the mnist test data CSV file into a list |
神经网络优化
至此,我们实现了一个手写数字识别的神经网络,并完成了评测。虽然评测得分还不错,正确率超过 95%,但是它还可以进一步优化。下面我们简单介绍几种优化思路。
调整学习率
调整学习率是最直观的一种优化思路。学习率过小会导致学习反馈不足,学习率过大会导致学习反馈震荡。下图所示是我们所实现神经网络的性能评分与与学习率的关系曲线。整体而言,我们应该不断调整,选择一个适中的学习率。
重复训练
重复训练也是一种经典的优化思路。我们将样本训练一次称为一个 世代(Epoch)。我们可以在样本训练的代码的外层再嵌套一层遍历,尝试进行多个世代的训练,如下所示。
1 | # train the neural network |
与学习率类似,过少的训练会导致学习反馈不足,过多的训练会导致网络过渡拟合训练数据。下图所示是性能评分与世代数的关系曲线,我们同样也需要进行不断调整世代数,寻找一个最佳值。
调整网络结构
调整网络结构也是一种优化思路。我们可以考虑调整隐藏层的节点数量。如果节点数太少的话,会导致节点无法承载过多的特征,从而表现不佳。下图所示是性能评测与隐藏层节点数之间的关系曲线,很显然节点越多,性能表现越好。当然这也是有代价的,节点数越多,神经网络的计算量就越大。
总结
本文基于神经网络的基本原理,使用 Python 依次实现了神经网络的初始化、训练、查询等部分。然后,我们使用 MNIST 的样本数据和测试数据依次来对神经网络进行训练和测试。最后,我们提了三种神经网络性能优化的思路,包括:学习率、训练量、网络结构等。
后续有时间,我们将再来深入学习一下机器学习的其他相关技术。