[深度学习][学习笔记]我的第一个神经网络:Hello World!

如果没有恐惧,死亡将会无人怜悯。
--白羊女的祈祷文
以此祭奠我不久前死去的喵星人

在上篇博文中,我大略介绍了一下神经网络到底是什么,希望能帮助没有接触过这个科技领域的同学对神经网络有一个感性的认知。本篇将通过简单的数学推论和 Python 代码实现来解释神经网络最基本的两个要素:

  • 感知器(Perceptron)
  • 梯度下降(Gradient Descent)

并在最后实现一个深度神经网络。为了方便解释基础运算过程,在本篇中我将仅使用numpy来进行数学运算。

感知器

神经网络最常见的使用场景即是给予一定输入信息后,能够处理信息,然后给出一个结果作为输出,这种输出可能是预测、分类结果或其它。在神经网络执行此类任务时,输入信息(通常是特征值)将被带入到一个相互连接的节点网络中。这些独立的节点被称作感知器或者神经元,它们是构成神经网络的基本单元。每个感知器依照输入数据来决定如何对数据分类。

以学校招生为例,以下是某学校对于往届申请学生的招收情况:
Screen-Shot-2017-12-05-at-6.01.31-PM

我们则可以在已知一个学生的高考分数和情商测试的情况下,预测其是否会被这所学校录取。根据往届信息看来,一个学生是否会被录取由高考分数和情商测试两个因素共同决定。这两个因素并没有任何一项对结果起决定性影响,而是各自占有一定权重(Weight)。假设我们已知这两个因素的各自的权重,则用来进行次学校录取预测的神经网络结构可能是:
Screen-Shot-2017-12-05-at-6.30.04-PM

当特征数据被输入感知器,它会与分配给这个特定输入的权重相乘。例如,上图感知器有两个输入,test和 iq,所以它有两个与之相关的权重,并且可以分别调整。一个较大的权重意味着神经网络认为这个输入比其它输入更重要,较小的权重意味着数据不是那么重要。一个极端的例子是,如果 test 成绩对学生录取没有影响,那么 test 分数的权重就会是零,也就是说,它对感知器的输出没有影响。

感知器把权重应用于输入再加总的过程叫做线性组合。通过简洁的数学表达方式即为:
Screen-Shot-2017-12-05-at-7.00.20-PM

通过以上的计算还不足以方便的预测出这个学生是否会被该学校录取,感知器求和的结果需要被转换成输出信号才能输出最终的结果。在这个例子中,输出结果可能是:

  • 0:录取
  • 1:不录取

这是需要通过把线性组合传给激活函数 f 来实现的。一个简单胜任的激活函数(activation function)可以是:
Screen-Shot-2017-12-05-at-7.07.27-PM

为了增加数学运算的功能完整性,这个公式中还将引入一个偏置项(bias)用来调整输出信号的大小。最终我们有了一个完整的感知器计算公式:
Screen-Shot-2017-12-05-at-7.15.42-PM-1

需要注意的是,在数据被整理得足够"好"(我们以后再聊聊怎样预处理数据)的情况下,我们并不需要偏置项。所以在后续的推到和代码中,你可能看不到偏置项的存在,不要惊讶。

这里给出感知器的Python实现样例:

import numpy as np

def activation(h):
    if h <= 0:
        return 0
    else:
        return 1

inputs = np.array([0.7, 0.3])
weights = np.random.normal(loc=0.0, scale=1, size=(1, 2))
bias = np.random.normal(loc=0.0, scale=1, size=(1))

output = activation(np.dot(weights, inputs) + bias)

print('Output: {}'.format(output))

总结看来,单个感知器的结构可以表示为下图左侧。如果要解决以上的预测问题,神经网络结构将不会如上图示例一样仅仅是一个感知器,而会是多个、多层感知器组合而成(下图右侧),一个感知器的输出可以变成另一个感知器的输入,经过多层后最终输出结果。一次神经网络预测运算将涉及其中所有感知器的运算,这一过程被称为正向传播
Screen-Shot-2017-12-05-at-10.33.04-PM

梯度下降

在有了以上的感知器后,就可以进行学生录取情况的预测工作了。但是不出意外的话,使用这样的神经网络并不能给出靠谱的预测,因为目前我们并不知道各个输入特征的权重值(weight)。使用不靠谱的权重自然不会得出像样的结果。好在我们有很多现成的历史数据,即我们知道什么样的学生已经被录取了,也知道什么样的学生没有被录取。我们可以将历史数据的学生信息带入神经网络,看看我们的神经网络所产生的输出结果和实际真是的结果有什么不同。然后根据结果不同的对比来修正权重。

这个过程被称为神经网络的训练,而那些现有的真实数据被称作训练数据集。神经网络刚刚被创建时权重和偏置项刚开始是随机值,当神经网络根据训练数据集学习到什么样的输入数据会使得什么样的输出结果之后,网络会根据之前权重和偏置项下分类的错误来调整它们。

为了做以上的骚操作,我们需要理清两件事情:

  • 怎样量化真实结果和网络输出结果的差距
  • 知道差距之后又怎样调整权重

关于输出结果差距的量化,一个很直觉的方法便是把真实结果 y 和计算出的结果 y^ 相减。但是这样并不是最好的方法,因为这会带来负数,不利于判断差值的大小。在此,我们用 yy^ 相减后的平方值来量化训练时每一次预测计算的差值。则在神经网络运行过所有的训练数据后,差值的总和为(为什么前面有个1/2?纯粹为了方便后面的演算):
Screen-Shot-2017-12-05-at-9.25.43-PM

这个值被称为SSE(Sum of Squared Errors of prediction)。为了使神经网络有尽可能好的表现,我们希望SSE越小越好,因为SSE越小,神经网络计算出的输出结果也就越贴近事实。

接下来的问题就是怎样调整权重和偏置项了。可以从公式中看出,SSE的大小和输入 x 和权重 w 相关。我们并不能对输入做什么手脚,在此只能考虑怎样对权重做出改动。为了使说明更加清晰,在此单独考虑一条数据记录了计算以及相对应的那个输出结果。假设SSE与权重 w 的关系如下图。
Screen-Shot-2017-12-05-at-9.34.19-PM

若要使得SSE最小化,权重需要在每个训练迭代中不停做出调整,直到最终到达是SSE最小的值。这个过程即是梯度下降。
Screen-Shot-2017-12-05-at-9.54.07-PM

权重调整的大小与当前 w 位置的梯度值相反,以下是一段公式推导:
Screen-Shot-2017-12-05-at-10.14.05-PM

以下为梯度下降的Python实现样例:

import numpy as np

# 这里使用sigmoid作为激活函数
def sigmoid(x):
    return 1 / (1 + np.exp(-x))

np.random.seed(42)

n_records, n_features = features.shape
last_loss = None

weights = np.random.normal(scale=1 / n_features**.5, size=n_features)

epochs = 1000
learnrate = 0.5

for e in range(epochs):
    del_w = np.zeros(weights.shape)
    for x, y in zip(features.values, targets):
        # 公式的力量
        output = sigmoid(np.dot(x, weights))
        error = y - output
        error_term = error * output * (1 - output)
        del_w += error_term * x

    weights += learnrate * del_w / n_records
    
    if e % (epochs / 10) == 0:
        out = sigmoid(np.dot(features, weights))
        loss = np.mean((out - targets) ** 2)
        print("Train loss: ", loss)

tes_out = sigmoid(np.dot(features_test, weights))
predictions = tes_out > 0.5
accuracy = np.mean(predictions == targets_test)
print("Prediction accuracy: {:.3f}".format(accuracy))

与正向传播相反,在复杂的网络结构中,权重从最后一层(结果输出)逐步向之前的网络层级更新,这一过程即是反向传播。虽然反向传播的发明者,深度学习教父Geoffrey Hinton不久前说过目前的反向传播有诸多缺陷,急需被取代。我们在仰望大神们新的研究成果的同时,反向传播仍是当下最有效的学习手段。

第一个神经网络

以下是一个仅用numpy实现的包含一个隐藏层的神经网络,激活函数分别是:

  • 隐藏层:sigmoid
  • 输出曾:linear
import numpy as np

class NeuralNetwork:
    def __init__(self, input_nodes, hidden_nodes, output_nodes, learning_rate,
            weights_input_to_hidden=None, weights_hidden_to_output=None):
        self.input_nodes = input_nodes
        self.hidden_nodes = hidden_nodes
        self.output_nodes = output_nodes

        # Initialize weights
        if type(weights_input_to_hidden).__name__ == 'NoneType' and type(weights_hidden_to_output).__name__ == 'NoneType':
            self.weights_input_to_hidden = np.random.normal(0.0, self.input_nodes**-0.5,
                                           (self.input_nodes, self.hidden_nodes))
            self.weights_hidden_to_output = np.random.normal(0.0, self.hidden_nodes**-0.5,
                                           (self.hidden_nodes, self.output_nodes))
        else:
            self.weights_input_to_hidden = weights_input_to_hidden
            self.weights_hidden_to_output = weights_hidden_to_output

        self.lr = learning_rate

        def sigmoid(x):
            return 1 / (1 + np.exp( -x ))

        def sigmoid_prime(x):
            return sigmoid(x) * (1 - sigmoid(x))

        def linear(x):
            return x

        def linear_prime(x):
            return x ** 0
        # Activation functions
        self.activation_function = sigmoid
        self.activation_function_prime = sigmoid_prime
        self.activation_function2 = linear
        self.activation_function_prime2 = linear_prime

    def train(self, features, targets):
        n_records = features.shape[0]
        delta_weights_i_h = np.zeros(self.weights_input_to_hidden.shape)
        delta_weights_h_o = np.zeros(self.weights_hidden_to_output.shape)

        for X, y in zip(features, targets):
            # Forward Pass
            hidden_inputs = np.dot(X, self.weights_input_to_hidden)
            hidden_outputs = self.activation_function(hidden_inputs)

            final_inputs = np.dot(hidden_outputs, self.weights_hidden_to_output)
            final_outputs = self.activation_function2(final_inputs)

            # Backward Pass
            error = y - final_outputs
            output_error_term = error * self.activation_function_prime2(final_outputs)

            hidden_error = np.dot(output_error_term, self.weights_hidden_to_output.T)
            hidden_error_term = hidden_error * self.activation_function_prime(hidden_inputs)

            # Weight steps
            delta_weights_i_h += hidden_error_term * X[:, None]
            delta_weights_h_o += output_error_term * hidden_outputs[:, None]

        self.weights_hidden_to_output += self.lr * delta_weights_h_o / n_records
        self.weights_input_to_hidden += self.lr * delta_weights_i_h / n_records

    def run(self, features):
        hidden_inputs = np.dot(features, self.weights_input_to_hidden)
        hidden_outputs = self.activation_function(hidden_inputs)

        final_inputs = np.dot(hidden_outputs, self.weights_hidden_to_output)
        final_outputs = self.activation_function2(final_inputs)

        return final_outputs

    def get_weights(self):
        return self.weights_input_to_hidden, self.weights_hidden_to_output