专栏首页深度学习自然语言处理基于NumPy手写神经网络

基于NumPy手写神经网络

来源:Medium

编译:weakish

来自:论智

编者按:和Piotr Skalski一起,基于NumPy手写神经网络,通过亲自动手实践,加深对神经网络内部机制的理解。

Keras、TensorFlow、PyTorch等高层框架让我们可以快速搭建复杂模型。然而,花一点时间了解下底层概念是值得的。前不久我发过一篇文章,以简单的方式解释了神经网络是如何工作的。但这是一篇高度理论性的文章,主要以数学为主(数学是神经网络超能力的来源)。我打算以实践的方式继续这一主题,在这篇文章中,我们将尝试仅仅基于NumPy创建一个可以工作的神经网络。最后,我们将测试一下创建的模型——用它来解决简单的分类问题,并和基于Keras搭建的神经网络比较一下。

显然,今天的文章会包含很多Python代码,我希望你不会因此觉得枯燥。我同时也把文中所有的代码发在GitHub上:SkalskiP/ILearnDeepLearning.py

概览

在开始编程之前,先让我们准备一份基本的路线图。我们的目标是创建一个特定架构(层数、层大小、激活函数)的密集连接神经网络。然后训练这一神经网络并做出预测。

上面的示意图展示了训练网络时进行的操作,以及单次迭代不同阶段需要更新和读取的参数。

初始化神经网络层

让我们从初始化每一层的权重矩阵W和偏置向量b开始。下图展示了网络层l的权重矩阵和偏置向量,其中,上标[l]表示当前层的索引,n表示给定层中的神经元数量。

我们的程序也将以类似的列表形式描述神经网络架构。列表的每一项是一个字典,描述单个网络层的基本参数:input_dim是网络层输入的信号向量的大小,output_dim是网络层输出的激活向量的大小,activation是网络层所用的激活函数。

nn_architecture = [
    {"input_dim": 2, "output_dim": 4, "activation": "relu"},
    {"input_dim": 4, "output_dim": 6, "activation": "relu"},
    {"input_dim": 6, "output_dim": 6, "activation": "relu"},
    {"input_dim": 6, "output_dim": 4, "activation": "relu"},
    {"input_dim": 4, "output_dim": 1, "activation": "sigmoid"},
]

如果你熟悉这一主题,你的脑海中大概已经回荡起焦急的声音:“喂喂!搞错了!这里有些是不必要的……”这一次,你内心的声音是对的,一个网络层的输出向量同时也是下一层的输入,所以其实只用列出两者之一就够了。不过,我故意使用这样的表示法,使每一层的格式保持一致,也让初次接触这一主题的人更容易理解代码。

def init_layers(nn_architecture, seed = 99):
    np.random.seed(seed)
    number_of_layers = len(nn_architecture)
    params_values = {}

    for idx, layer in enumerate(nn_architecture):
        layer_idx = idx + 1
        layer_input_size = layer["input_dim"]
        layer_output_size = layer["output_dim"]

        params_values['W' + str(layer_idx)] = np.random.randn(
            layer_output_size, layer_input_size) * 0.1
        params_values['b' + str(layer_idx)] = np.random.randn(
            layer_output_size, 1) * 0.1

    return params_values

上面的代码初始化了网络层的参数。注意我们用随机的小数字填充矩阵W和向量b。这并不是偶然的。权重值无法使用相同的数字初始化,否则会造成破坏性的对称问题基本上,如果权重都一样,不管输入X是什么,隐藏层的所有单元也都一样。这样,我们就会陷入初始状态,不管训练多久,网络多深,都无望摆脱。线性代数不会原谅我们。

小数值增加了算法的效率。我们可以看看下面的sigmoid函数图像,大数值处的函数图像几乎是扁平的,这会对神经网络的学习速度造成显著影响。所有参数使用小随机数是一个简单的方法,但它保证了算法有一个足够好的开始。

激活函数

激活函数只需一行代码就可以定义,但它们给神经网络带来了非线性和所需的表达力。“没有它们,神经网络将变成线性函数的组合,也就是单个线性函数。”激活函数有很多种,但在这个项目中,我决定使用其中两种——sigmoid和ReLU。为了同时支持前向传播和反向传播,我们还需要准备好它们的导数。

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

def relu(Z):
    return np.maximum(0,Z)

def sigmoid_backward(dA, Z):
    sig = sigmoid(Z)
    return dA * sig * (1 - sig)

def relu_backward(dA, Z):
    dZ = np.array(dA, copy = True)
    dZ[Z <= 0] = 0;
    return dZ

前向传播

我们设计的神经网络有一个简单的架构。输入矩阵X传入网络,沿着隐藏单元传播,最终得到预测向量Y_hat。为了让代码更易读,我将前向传播拆分成两个函数——单层前向传播,和整个神经网络前向传播。

def single_layer_forward_propagation(A_prev, W_curr, b_curr, activation="relu"):
    Z_curr = np.dot(W_curr, A_prev) + b_curr

    if activation is "relu":
        activation_func = relu
    elif activation is "sigmoid":
        activation_func = sigmoid
    else:
        raise Exception('Non-supported activation function')

    return activation_func(Z_curr), Z_curr

这部分代码大概是最直接,最容易理解的。给定来自上一层的输入信号,我们计算仿射变换Z,接着应用选中的激活函数。基于NumPy,我们可以对整个网络层和整批样本一下子进行矩阵操作,无需迭代,这大大加速了计算。除了计算结果外,函数还返回了一个反向传播时需要用到的中间值Z

基于单层前向传播函数,编写整个前向传播步骤很容易。这是一个略微复杂一点的函数,它的角色不仅是进行预测,还包括组织中间值。

def full_forward_propagation(X, params_values, nn_architecture):
    memory = {}
    A_curr = X

    for idx, layer in enumerate(nn_architecture):
        layer_idx = idx + 1
        A_prev = A_curr

        activ_function_curr = layer["activation"]
        W_curr = params_values["W" + str(layer_idx)]
        b_curr = params_values["b" + str(layer_idx)]
        A_curr, Z_curr = single_layer_forward_propagation(A_prev, W_curr, b_curr, activ_function_curr)

        memory["A" + str(idx)] = A_prev
        memory["Z" + str(layer_idx)] = Z_curr

    return A_curr, memory

损失函数

损失函数可以监测进展,确保我们向着正确的方向移动。“一般来说,损失函数是为了显示我们离‘理想’解答还有多远。”损失函数根据我们计划解决的问题而选用,Keras之类的框架提供了很多选项。因为我计划将神经网络用于二元分类问题,我决定使用交叉熵:

为了取得更多关于学习过程的信息,我决定另外实现一个计算精确度的函数。

def get_cost_value(Y_hat, Y):
    m = Y_hat.shape[1]
    cost = -1 / m * (np.dot(Y, np.log(Y_hat).T) + np.dot(1 - Y, np.log(1 - Y_hat).T))
    return np.squeeze(cost)

def get_accuracy_value(Y_hat, Y):
    Y_hat_ = convert_prob_into_class(Y_hat)
    return (Y_hat_ == Y).all(axis=0).mean()

反向传播

不幸的是,很多缺乏经验的深度学习爱好者都觉得反向传播很吓人,难以理解。微积分和线性代数的组合经常会吓退那些没有经过扎实的数学训练的人。所以不要过于担心你现在还不能理解这一切。相信我,我们都经历过这个过程。

def single_layer_backward_propagation(dA_curr, W_curr, b_curr, Z_curr, A_prev, activation="relu"):
    m = A_prev.shape[1]

    if activation is "relu":
        backward_activation_func = relu_backward
    elif activation is "sigmoid":
        backward_activation_func = sigmoid_backward
    else:
        raise Exception('Non-supported activation function')

    dZ_curr = backward_activation_func(dA_curr, Z_curr)
    dW_curr = np.dot(dZ_curr, A_prev.T) / m
    db_curr = np.sum(dZ_curr, axis=1, keepdims=True) / m
    dA_prev = np.dot(W_curr.T, dZ_curr)

    return dA_prev, dW_curr, db_curr

人们经常搞混反向传播和梯度下降,但事实上它们不一样。前者是为了高效地计算梯度,后者则是为了基于计算出的梯度进行优化。在神经网络中,我们计算损失函数在参数上的梯度,但反向传播可以用来计算任何函数的导数。反向传播算法的精髓在于递归地使用求导的链式法则,通过组合导数已知的函数,计算函数的导数。下面的公式描述了单个网络层上的反向传播过程。由于本文的重点在实际实现,所以我将省略求导过程。从公式上我们可以很明显地看到,为什么我们需要在前向传播时记住中间层的AZ矩阵的值。

和前向传播一样,我决定将计算拆分成两个函数。之前给出的是单个网络层的反向传播函数,基本上就是以NumPy方式重写上面的数学公式。而定义完整反向传播过程的函数,主要是读取、更新三个字典中的值。

def full_backward_propagation(Y_hat, Y, memory, params_values, nn_architecture):
    grads_values = {}
    m = Y.shape[1]
    Y = Y.reshape(Y_hat.shape)

    dA_prev = - (np.divide(Y, Y_hat) - np.divide(1 - Y, 1 - Y_hat));

    for layer_idx_prev, layer in reversed(list(enumerate(nn_architecture))):
        layer_idx_curr = layer_idx_prev + 1
        activ_function_curr = layer["activation"]

        dA_curr = dA_prev

        A_prev = memory["A" + str(layer_idx_prev)]
        Z_curr = memory["Z" + str(layer_idx_curr)]
        W_curr = params_values["W" + str(layer_idx_curr)]
        b_curr = params_values["b" + str(layer_idx_curr)]

        dA_prev, dW_curr, db_curr = single_layer_backward_propagation(
            dA_curr, W_curr, b_curr, Z_curr, A_prev, activ_function_curr)

        grads_values["dW" + str(layer_idx_curr)] = dW_curr
        grads_values["db" + str(layer_idx_curr)] = db_curr

    return grads_values

基于单个网络层的反向传播函数,我们从最后一层开始迭代计算所有参数上的导数,并最终返回包含所需梯度的python字典。

更新参数值

反向传播是为了计算梯度,以根据梯度进行优化,更新网络的参数值。为了完成这一任务,我们将使用两个字典作为函数参数:params_values,其中保存了当前参数值;grads_values,其中保存了用于更新参数值所需的梯度信息。现在我们只需在每个网络层上应用以下等式即可。这是一个非常简单的优化算法,但我决定使用它作为更高级的优化算法的起点(大概会是我下一篇文章的主题)。

def update(params_values, grads_values, nn_architecture, learning_rate):
    for idx, layer in enumerate(nn_architecture):
        layer_idx = idx + 1
        params_values["W" + str(layer_idx)] -= learning_rate * grads_values["dW" + str(layer_idx)]
        params_values["b" + str(layer_idx)] -= learning_rate * grads_values["db" + str(layer_idx)]

    return params_values

整合一切

万事俱备只欠东风。最困难的部分已经完成了——我们已经准备好了所需的函数,现在只需以正确的顺序把它们放到一起。

def train(X, Y, nn_architecture, epochs, learning_rate):
    params_values = init_layers(nn_architecture, 2)
    cost_history = []
    accuracy_history = []

    for i in range(epochs):
        Y_hat, cashe = full_forward_propagation(X, params_values, nn_architecture)
        cost = get_cost_value(Y_hat, Y)
        cost_history.append(cost)
        accuracy = get_accuracy_value(Y_hat, Y)
        accuracy_history.append(accuracy)

        grads_values = full_backward_propagation(Y_hat, Y, cashe, params_values, nn_architecture)
        params_values = update(params_values, grads_values, nn_architecture, learning_rate)

    return params_values, cost_history, accuracy_history

大卫对战歌利亚

该是看看我们的模型能不能解决一个简单的分类问题的时候了。我生成了一个包含两个分类的数据点的数据集,如下图所示。

作为对比,我用高层框架Keras搭建了一个模型。两个模型采用相同的架构和学习率。不过这仍然是一场不公平的较量,因为我们的模型用的都是最简单的方案。最终,基于NumPy和Keras的模型在测试集上都取得了类似的精确度(95%)。不过,我们的模型收敛的速度要慢很多倍。在我看来,这主要是因为我们的模型缺乏恰当的优化算法。

再见

希望我的文章拓展了你的视野,增加了你对神经网络内部运作机制的理解——这将是对我投入精力撰写本文的最好回报。我在编写代码和说明的时候也学到了很多东西,亲自动手实践能让你学到很多东西。

保持好奇心!

IELTS a bit

hazard n. 危险;有危险的事物;风险

incline v. 倾向于

junction n. 公路或铁路的交叉点;交叉路口

本文分享自微信公众号 - 深度学习自然语言处理(zenRRan)

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2018-10-20

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 【NLP】自然语言处理中词性、短语、短语关系标签的具体含义列表

    ROOT:要处理文本的语句 IP:简单从句 NP:名词短语 VP:动词短语 PU:断句符,通常是句号、问号、感叹号等标点符号 LCP:方位词短语 PP:介词短语...

    zenRRan
  • Python学习——collections系列

    一 ,计数器(counter) Counter是对字典类型的补充,用于追踪值得出现次数 ps:具备字典的所有功能 + 自己的功能 例: >>> from ...

    zenRRan
  • 速收藏!最强数据集50个最佳机器学习公共数据!

    https://medium.com/datadriveninvestor/the-50-best-public-datasets-for-machine-le...

    zenRRan
  • 【ML系列】手把手教你用Numpy构建神经网络!(附代码)

    比如使用Keras,TensorFlow或PyTorch这样的高级框架,我们可以快速构建非常复杂的模型。但是,需要花时间去了解其内部结构并理解基本原理。今天,将...

    量化投资与机器学习微信公众号
  • 用wxPython打造Python图形界面(上)

    有许多图形用户界面(GUI)工具包可以与Python编程语言一起使用。其中三巨头是Tkinter、wxPython和PyQt。这些工具包中的每一个都将与Wind...

    AiTechYun
  • PCL常见错误集锦

    我刚刚开始接触PCL,懂的东西也很少,所以总是出现各种各样的问题,每次遇见问题的时候要查找各种各样的资料,很费时间。所以,今天我把我遇见的常见问题分享给大家,讲...

    点云PCL博主
  • 超越Google,腾讯推出自研图片编码格式TPG

    近日,记者从国家知识产权局了解到,腾讯公司正式向国家知识产权局提交了一份关于图片编码技术的专利申请。此项专利被命名为TPG(Tiny Portable Grap...

    腾讯音视频实验室
  • 超越Google,腾讯推出自研图片编码格式TPG

    腾讯公司正式向国家知识产权局提交了一份关于图片编码技术的专利申请,此项专利被命名为TPG(Tiny Portable Graphics),在数据上TPG图片格式...

    腾讯高校合作
  • 超越 Google,腾讯推出自研图片编码格式 TPG

    近日,记者从国家知识产权局了解到,腾讯公司正式向国家知识产权局提交了一份关于图片编码技术的专利申请。此项专利被命名为TPG(Tiny Portable Grap...

    云资讯小编
  • 混合云存储:大数据应用的上云之道

    ? 企业数字化转型过程中,数据价值被显著放大,大数据应用成为不少企业探索的重点。 从技术上看,大数据业务由于数据体量大,且数据量很多时候呈急速膨胀状态;在进...

    云存储

扫码关注云+社区

领取腾讯云代金券