初识人工神经网络(2)——代码实现

前一篇 文章 我们介绍了人工神经网络的基本原理,本文我们将使用 Python 来实现一个简易的神经网络,可用于识别手写数字,从而加深对于神经网络的理解。

本文实现的完整代码 传送门

神经网络实现

根据我们的理解,神经网络应该至少包含三个部分:

  • 初始化:初始化输入层节点、隐藏层节点、输出层节点的数量。
  • 训练:通过特定的训练样本,优化连接权重。
  • 查询:给定输入,计算输出。

对此,我们定义一个 NeuralNetwork 类来表示神经网络,其内部包含三个函数分别对应初始化、训练、查询,如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
# neural network class definition 
class NeuralNetwork :
# initialise the neural network
def __init__() :
pass

# train the neural network
def train() :
pass

# query the neural network
def query() :
pass

接下来,我们依次来实现神经网络的各个部分。

初始化网络

网络结构

首先,我们定义神经网络的基本结构,其包含三个层:输入层、隐藏层、输出层。此外,我们还需要定义学习率,代码如下所示。

1
2
3
4
5
6
7
8
9
10
# initialise the neural network
def __init__( self , inputnodes, hiddennodes, outputnodes, learningrate ) :
# set number of nodes in each input, hidden, output layer
self.inodes = inputnodes
self.hnodes = hiddennodes
self.onodes = outputnodes

# learning rate
self.lr = learningrate
pass

连接权重

其次,定义神经网络的核心参数——连接权重。这里涉及两部分连接权重,分别是:

  • 输入层与隐藏层之间的连接权重矩阵:\(W_{input\_hidden}\),其大小为 \(hidden\_nodes \times input\_nodes\)
  • 隐藏层与输出层之间的连接权重矩阵:\(W_{hidden\_output}\),其大小为 \(hidden\_nodes \times output\_nodes\)

在 Python 中,我们使用经典的数学库 numpy 来实现矩阵的表示和运算,通过如下的方式我们可以定义一个 \(rows \times columns\) 的数组,数组元素是 0 ~ 1 的随机值。

1
numpy.random.rand(rows, columns)

在前一篇文章中,我们介绍过权重的范围在 \([-1. 1]\) 之间。这里我们可以通过上述方式生成随机数之后再减去 0.5,从而使数组元素变成 \([-0.5, 0.5]\) 之间的随机值,如下所示。

1
2
self.wih = (numpy.random.rand(self.hnodes, self.inodes) - 0.5)
self.who = (numpy.random.rand(self.onodes, self.hnodes) - 0.5)

我们之前还提到过一种优化的权重初始化方案:在一个节点传入连接数量平方根倒数的范围内进行正态分布采样,即权重范围是 \([1/\sqrt{传入连接数}, -1/\sqrt{传入连接数}]\),相关代码实现如下所示。其中 numpy.random.normal() 函数用于实现正态分布采样,传入的三个参数分别是:正态分布值的中心、标准方差、数组大小。pow(self.hnodes, -0.5) 相当于节点数量的 -0.5 次方。

1
2
self.wih = numpy.random.normal( 0.0 , pow(self.hnodes, -0.5) , (self.hnodes, self.inodes) )
self.who = numpy.random.normal( 0.0 , pow(self.onodes, -0.5) , (self.onodes, self.hnodes) )

最后我们结合网络结构和连接权重,可以得到完整的初始化代码,如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
# initialise the neural network
def __init__( self , inputnodes, hiddennodes, outputnodes, learningrate ) :
# set number of nodes in each input, hidden, output layer
self.inodes = inputnodes
self.hnodes = hiddennodes
self.onodes = outputnodes

self.wih = numpy.random.normal ( 0.0 , pow(self.hnodes, -0.5) , (self.hnodes, self.inodes) )
self.who = numpy.random.normal ( 0.0 , pow(self.onodes, -0.5) , (self.onodes, self.hnodes) )

# learning rate
self.lr = learningrate
pass

查询网络

查询网络本质上就是信号转换的过程。我们知道每一个神经元都会对输入信号进行两次处理,分别是求和函数和激活函数,对应的矩阵表达式如下所示。

\[\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)

对于激活函数,我们使用 \(sigmoid\) 激活函数,它的表达式是 \(\frac{1}{1+e^{-x}}\)。对此,SciPy Python 库中的 expit() 函数实现了 \(sigmoid\) 函数。我们可以在初始化方法中加入一下这段代码。

1
self.activation_function = lambda x: scipy.special.expit(x)

然后,我们将上述求和函数结果输入至激活函数中,即可得到隐藏层的输出,如下所示。

1
hidden_outputs = self.activation_function(hidden_inputs)

输出层的信号转换本质上与隐藏层一样,因此我们可以添加两行类似的代码来对输出层进行处理。

1
2
final_inputs = numpy.dot(self.who, hidden_outputs)
final_outputs = self.activation_function(final_inputs)

最后我们得到完整的查询网络代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# query the neural network
def query(self, inputs_list) :
# convert inputs list to 2d array
inputs = numpy.array(inputs_list, ndmin=2).T

# calculate signals into hidden layer
hidden_inputs = numpy.dot(self.wih, inputs)
# calculate the signals emerging from hidden layer
hidden_outputs = self.activation_function(hidden_inputs)

# calculate signals into final output layer
final_inputs = numpy.dot(self.who, hidden_outputs)
# calculate the signals emerging from final output layer
final_outputs = self.activation_function(final_inputs)

return final_outputs

训练网络

训练网络主要包含两部分:

  • 正向的信号转换
  • 反向的权重更新

信号转换

信号转换的过程与上述查询网络一致,因此我们可以直接照搬相关代码,如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def train(self, inputs_list, targets_list):
# convert inputs list to 2d array
inputs = numpy.array(inputs_list, ndmin=2).T
targets = numpy.array(targets_list, ndmin=2).T

# calculate signals into hidden layer
hidden_inputs = numpy.dot(self.wih, inputs)
# calculate the signals emerging from hidden layer
hidden_outputs = self.activation_function(hidden_inputs)

# calculate signals into final output layer
final_inputs = numpy.dot(self.who, hidden_outputs)
# calculate the signals emerging from final output layer
final_outputs = self.activation_function(final_inputs)

pass

权重更新

对于一个三层神经网络,我们只需要更新两部分权重,分别是:

  • 隐藏层与输出层的权重更新
  • 输入层与隐藏层的权重更新

权重更新是基于误差实现的,因此我们首先要计算误差,对于输出层的误差,计算样本的预期目标输出值与实际计算输出值的差即可;对于隐藏层的误差,我们在上一篇文章中进行了公式推导,如下所示。

\[\begin{aligned} error_{hidden} = W^T_{hidden\_output} \cdot error_{output} \end{aligned}\]

由此,我们可以得到如下所示的误差计算代码。

1
2
3
4
5
# error is the (target - actual)
output_errors = targets - final_outputs

# hidden layer error is the output_errors, split by weights, recombined at hidden nodes
hidden_errors = numpy.dot(self.who.T, output_errors)

然后,我们根据上一篇文章中推导的权重更新公式来实现相关代码,其公式如下所示。其中 \(\alpha\) 是学习率。基于此,我们可以分别实现隐藏层与输出层的权重更新、输入层与隐藏层的权重更新。

1
2
3
4
5
# update the weights for the links between the hidden and output layers
self.who += self.lr * numpy.dot(( output_errors * final_outputs * (1.0 - final_outputs)), numpy.transpose(hidden_outputs))

# update the weights for the links between the input and hidden layers
self.wih += self.lr * numpy.dot(( hidden_errors * hidden_outputs * (1.0 - hidden_outputs)), numpy.transpose(inputs))

最后,我们可以得到完整的训练网络的相关代码,如下所示。

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
def train(self, inputs_list, targets_list):
# convert inputs list to 2d array
inputs = numpy.array(inputs_list, ndmin=2).T
targets = numpy.array(targets_list, ndmin=2).T

# calculate signals into hidden layer
hidden_inputs = numpy.dot(self.wih, inputs)
# calculate the signals emerging from hidden layer
hidden_outputs = self.activation_function(hidden_inputs)

# calculate signals into final output layer
final_inputs = numpy.dot(self.who, hidden_outputs)
# calculate the signals emerging from final output layer
final_outputs = self.activation_function(final_inputs)

# output layer error is the (target - actual)
output_errors = targets - final_outputs
# hidden layer error is the output_errors, split by weights, recombined at hidden nodes
hidden_errors = numpy.dot(self.who.T, output_errors)

# update the weights for the links between the hidden and output layers
self.who += self.lr * numpy.dot((output_errors * final_outputs * (1.0 - final_outputs)), numpy.transpose(hidden_outputs))

# update the weights for the links between the input and hidden layers
self.wih += self.lr * numpy.dot((hidden_errors * hidden_outputs * (1.0 - hidden_outputs)), numpy.transpose(inputs))

pass

神经网络测试

神经网络适合用于解决不具有固定模式或计算步骤的问题,比如:图像识别、语音识别等。这里我们尝试让神经网络解决一个类似的问题——手写数字识别。下图所示是一个手写数字的示例,我们可能会对于这个数字是 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.csvmnist_test.csv 中的每一行代表一个样本数据,每个样本数据由 785 个数字组成,由逗号进行分隔。其中第 1 个数字是样本的预期目标值;其余 784 个数字素是样本的输入数据,本质上是一个打平的 28 x 28 的二维矩阵,每个数字表示一个颜色值,范围在 \([0, 255]\) 之间。如下所示,是一个样本数据的示例。

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
2
3
4
5
6
7
8
9
10
# number of input, hidden and output nodes
input_nodes = 784
hidden_nodes = 100
output_nodes = 10

# learning rate is 0.3
learning_rate = 0.3

# create instance of neural network
n = neuralNetwork(input_nodes,hidden_nodes,output_nodes, learning_rate)

样本数据预处理

在上一篇文章中我们提到输入值的范围应该是 \((0, 1]\),而当前的样本输入值的范围是 \([0, 255]\)。因此我们需要进行样本数据处理,可以考虑将其缩小至 \([0.01, 1.0]\) 的范围之中,相关的处理代码如下所示。

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
2
3
4
#output nodes is 10 (example)
onodes = 10
targets = numpy.zeros(onodes) + 0.01
targets[int(all_values[0])] = 0.99

如下所示是输入数据预处理的相关代码实现。我们首先打开样本数据的 csv 文件,然后循环依次遍历每一个样本数据,拆分成目标值和输入值,最后对它们进行预处理,从而匹配神经网络的值的约束。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# load the mnist training data CSV file into a list 
training_data_file = open("./mnist_train.csv", 'r')
training_data_list = training_data_file.readlines()
training_data_file.close()

# go through all records in the training data set
for record in training_data_list:
# split the record by the ',' commas
all_values = record.split(',')
# scale and shift the inputs
inputs = (numpy.asfarray(all_values[1:]) / 255.0 * 0.99) + 0.01
# create the target output values (all 0.01, except the desired label which is 0.99)
targets = numpy.zeros(output_nodes) + 0.01
# all_values[0] is the target label for this record
targets[int(all_values[0])] = 0.99

# train the neural network
n.train(inputs, targets)

pass

训练与测试

我们使用 mnist_train.csv 完成了训练之后,可以使用 mnist_test.csv 来进行测试。我们只要再读取测试数据,输入至已训练的神经网络中查询结果,并比对输出值与目标值。为了有一个衡量标准,我们对每次样本测试进行打分,查询结果正确 +1,最后计算正确率,相关代码实现如下所示。

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
# load the mnist test data CSV file into a list
test_data_file = open("./mnist_test.csv", 'r')
test_data_list = test_data_file.readlines()
test_data_file.close()

# test the neural network
# scorecard for how well the network performs, initially empty
scorecard = []

# go through all the records in the test data set
for record in test_data_list:
# split the record by the ',' commas
all_values = record.split(',')
# correct answer is first value
correct_label = int(all_values[0])
# scale and shift the inputs
inputs = (numpy.asfarray(all_values[1:]) / 255.0 * 0.99) + 0.01
# query the network
outputs = n.query(inputs)
# the index of the highest value corresponds to the label
label = numpy.argmax(outputs)
# append correct or incorrect to list
if (label == correct_label):
# network's answer matches correct answer, add 1 to scorecard
scorecard.append(1)
else:# network's answer doesn't match correct answer, add 0 to scorecard
scorecard.append(0)
pass
pass

# calculate the performance score, the fraction of correct answers
scorecard_array = numpy.asarray(scorecard)
print ("performance = ", scorecard_array.sum() / scorecard_array.size)

神经网络优化

至此,我们实现了一个手写数字识别的神经网络,并完成了评测。虽然评测得分还不错,正确率超过 95%,但是它还可以进一步优化。下面我们简单介绍几种优化思路。

调整学习率

调整学习率是最直观的一种优化思路。学习率过小会导致学习反馈不足,学习率过大会导致学习反馈震荡。下图所示是我们所实现神经网络的性能评分与与学习率的关系曲线。整体而言,我们应该不断调整,选择一个适中的学习率。

重复训练

重复训练也是一种经典的优化思路。我们将样本训练一次称为一个 世代(Epoch)。我们可以在样本训练的代码的外层再嵌套一层遍历,尝试进行多个世代的训练,如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# train the neural network

# epochs is the number of times the training data set is used for training
epochs = 5

for e in range(epochs):
for record in training_data_list:
# split the record by the ', commas
all_values = record.split(',')
# scale and shift the inputs
inputs = (numpy.asfarray(all_values[1:]) / 255.0 * 0.99) + 0.01
# create the target output values (all 0.01, except the desired label which is 0.99)
targets = numpy.zeros(output_nodes) + 0.01
# all_values[0] is the target label for this record
targets[int(all_values[0])] = 0.99
n.train(inputs, targets)
pass
pass

与学习率类似,过少的训练会导致学习反馈不足,过多的训练会导致网络过渡拟合训练数据。下图所示是性能评分与世代数的关系曲线,我们同样也需要进行不断调整世代数,寻找一个最佳值。

调整网络结构

调整网络结构也是一种优化思路。我们可以考虑调整隐藏层的节点数量。如果节点数太少的话,会导致节点无法承载过多的特征,从而表现不佳。下图所示是性能评测与隐藏层节点数之间的关系曲线,很显然节点越多,性能表现越好。当然这也是有代价的,节点数越多,神经网络的计算量就越大。

总结

本文基于神经网络的基本原理,使用 Python 依次实现了神经网络的初始化、训练、查询等部分。然后,我们使用 MNIST 的样本数据和测试数据依次来对神经网络进行训练和测试。最后,我们提了三种神经网络性能优化的思路,包括:学习率、训练量、网络结构等。

后续有时间,我们将再来深入学习一下机器学习的其他相关技术。