Piotr Skalski作者高璇、张倩参与

如何只用 NumPy 码一个神经网络

Keras、TensorFlow、PyTorch 等高级框架可以帮助我们快速构建复杂模型。深入研究并理解其中的理念很有价值。不久前,本文作者发表了一篇文章(参见《资源 | 来自独秀同学的深度网络数学笔记,还不快收藏?》),简明扼要地解释了神经网络的工作原理,但那篇文章偏向于数学理论知识。所以作者打算以一种更实际的方式来跟进这一话题。他们尝试只使用 NumPy 构建一个全运算的神经网络,通过解决简单的分类问题来测试模型,并将其与 Keras 构建的神经网络进行性能比较。

注:本文将包含大量用 Python 编写的代码片段。希望读起来不会太无聊。:) 所有源代码都可以在作者的 GitHub 上找到。链接:https://github.com/SkalskiP/ILearnDeepLearning.py

图 1 :密集神经网络架构

磨刀不误砍柴工

在开始编程之前,需要先整理一个基本的路线图。我们的目标是创建一个程序,该程序能创建一个拥有特定架构(层的数量和大小以及激活函数都是确定的)的密集连接神经网络。图 1 给出了网络的示例。最重要的是,网络必须可训练且能进行预测。

图 2 :神经网络框图

上图显示了在训练神经网络时需要执行的操作。它还显示了在单次迭代的不同阶段,需要更新和读取多少参数。构建正确的数据结构并熟练地管理其状态是任务中最困难的部分之一。

图 3 :l 层的权值矩阵 W 和偏置向量 b 的维数。

神经网络层初始化

首先初始化每一层的权值矩阵 W 和偏置向量 b。在图 3 中。先准备一个为系数分配适当维数的清单。上标 [l] 表示当前层的索引 (从 1 数起),值 n 表示给定层中的单位数。假设描述 NN 架构的信息将以类似 Snippet 1 的列表形式传递到程序中,列表的每一项是一个描述单个网络层基本参数的字典: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"},
]

Snippet 1:包含描述特定神经网络参数的列表。该列表对应图 1 所示的 NN。

如果你对这个话题很熟悉,你可能已经在脑海中听到一个焦虑的声音:「嘿,嘿!这里有问题!有些领域是不必要的……」是的,这次你内心的声音是对的。前一层输出的向量是下一层的输入,所以实际上只知道一个向量的大小就足够了。但我特意使用以下符号来保持所有层之间目标的一致性,使那些刚接触这一课题的人更容易理解代码。

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

Snippet 2:初始化权值矩阵和偏置向量值的函数。

最后是这一部分最主要的任务——层参数初始化。看过 Snippet 2 上的代码并对 NumPy 有一定经验的人会发现,矩阵 W 和向量 b 被小的随机数填充。这种做法并非偶然。权值不能用相同的数字初始化,不然会出现「对称问题」。如果所有权值一样,不管输入 X 是多少,隐藏层中的所有单位都相同。在某种程度上,我们在初始阶段就会陷入死循环,无论训练模型时间多长、网络多深都无法逃脱。线性代数是不会被抵消的。

在第一次迭代中,使用较小的数值可以提高算法效率。通过图 4 所示的 sigmoid 函数图可以看到,对于较大数值,它几乎是平的,这十分影响 NN 的学习速度。总之,使用小随机数进行参数初始化是一种简单的方法,能保证我们的算法有足够好的起点。准备好的参数值存储在带有唯一标定其父层的 python 字典中。字典在函数末尾返回,因此算法的下一步是访问它的内容。

图 4:算法中使用的激活函数

激活函数

我们将使用的函数中,有几个函数非常简单但功能强大。激活函数可以写在一行代码中,但却能使神经网络表现出自身所需的非线性性能和可表达性。「没有它们,我们的神经网络就会变成由多个线性函数组合而成的线性函数。」可选激活函数很多,但在这个项目中,我决定使用这两种——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;

Snippet 3:ReLU 和 Sigmoid 激活函数及其导数。

前向传播

设计好的神经网络有一个简单的架构。信息以 X 矩阵的形式沿一个方向传递,穿过隐藏的单元,从而得到预测向量 Y_hat。为了便于阅读,我将前向传播分解为两个单独的函数——对单个层进行前向传播和对整个 NN 进行前向传播。

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

Snippet 4:单层前向传播步骤

这部分代码可能是最容易理解的。给定上一层的输入信号,我们计算仿射变换 Z,然后应用选定的激活函数。通过使用 NumPy,我们可以利用向量化——一次性对整个层和整批示例执行矩阵运算。这减少了迭代次数,大大加快了计算速度。除了计算矩阵 A,我们的函数还返回一个中间值 Z。作用是什么呢?答案如图 2 所示。我们需要在反向传播中用到 Z。

图 5 :在前向传播中使用的单个矩阵的维数。

使用预设好的一层前向函数后,就可以轻松地构建整个前向传播。这个函数稍显复杂,它的作用不仅是预测,还要管理中间值的集合。它返回 Python 字典,其中包含为特定层计算的 A 和 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

Snippnet 5:完整前向传播步骤

损失函数

为了观察进度,保证正确方向,我们通常需要计算损失函数的值。「一般来说,损失函数用来表征我们与『理想』解决方案的距离。」我们根据要解决的问题来选择损失函数,像 Keras 这样的框架会有多种选择。因为我计划测试我们的 NN 在两类点上的分类,所以选择二进制交叉熵,它定义如下。为了获得更多学习过程的信息,我决定引入一个计算准确率的函数。

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()

Snippnet 6:损失函数准确率计算

反向传播

许多缺乏经验的深度学习爱好者认为反向传播是一种难以理解的算法。微积分和线性代数的结合常常使缺乏数学基础的人望而却步。所以如果你无法马上理解,也不要担心。相信我,我们都经历过这个过程。

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

Snippnet 7:单层反向传播步骤

人们常常混淆反向传播与梯度下降,但实际上这是两个独立的问题。前者的目的是有效地计算梯度,而后者是利用计算得到的梯度进行优化。在 NN 中,我们计算关于参数的代价函数梯度(之前讨论过),但是反向传播可以用来计算任何函数的导数。这个算法的本质是在已知各个函数的导数后,利用微分学中的链式法则计算出结合成的函数的导数。对于一层网络,这个过程可用下面的公式描述。本文主要关注的是实际实现,故省略推导过程。通过公式可以看出,预先记住中间层的 A 矩阵和 Z 矩阵的值是十分必要的。


图 6:一层中的前向和反向传播。

就像前向传播一样,我决定将计算分为两个独立的函数。第一个函数(Snippnet7)侧重一个单独的层,可以归结为用 NumPy 重写上面的公式。第二个表示完全反向传播,主要在三个字典中读取和更新值。然后计算预测向量(前向传播结果)的代价函数导数。这很简单,它只是重述了下面的公式。然后从末端开始遍历网络层,并根据图 6 所示的图计算所有参数的导数。最后,函数返回 python 字典,其中就有我们想求的梯度。

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

Snippnet 8:全反向传播步骤

更新参数

该方法的目标是利用梯度优化来更新网络参数,以使目标函数更接近最小值。为了实现这项任务,我们使用两个字典作为函数参数:params_values 存储参数的当前值;grads_values 存储根据参数计算出的代价函数导数。虽然该优化算法非常简单,只需对每一层应用下面的方程即可,但它可以作为更高级优化器的一个良好起点,所以我决定使用它,这也可能是我下一篇文章的主题。

def update(params_values, grads_values, nn_architecture, learning_rate):
    for layer_idx, layer in enumerate(nn_architecture):
        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;

Snippnet 9:利用梯度下降更新参数

组合成型

任务中最困难的部分已经过去了,我们已经准备好了所有必要的函数,现在只需把它们按正确的顺序组合即可。为了更好地理解操作顺序,需要对照图 2 的表。该函数经过训练和期间的权值变化返回了最优权重。只需要使用接收到的权重矩阵和一组测试数据即可运行完整的前向传播,从而进行预测。

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

Snippnet 10:训练模型

David vs Goliath

现在可以检验我们的模型在简单的分类问题上的表现了。我生成了一个由两类点组成的数据集,如图 7 所示。然后让模型学习对两类点分类。为了便于比较,我还在高级框架中编写了 Keras 模型。两种模型具有相同的架构和学习速率。尽管如此,这样对比还是稍有不公,因为我们准备的测试太过于简单。最终,NumPy 模型和 Keras 模型在测试集上的准确率都达到了 95%,但是我们的模型需要多花几十倍的时间才能达到这样的准确率。在我看来,这种状态主要是由于缺乏适当的优化。

图 7:测试数据集

图 8:两种模型实现的分类边界可视化


原文链接:https://towardsdatascience.com/lets-code-a-neural-network-in-plain-numpy-ae7e74410795

工程神经网络激活函数Numpy
2
相关数据
深度学习技术

深度学习(deep learning)是机器学习的分支,是一种试图使用包含复杂结构或由多重非线性变换构成的多个处理层对数据进行高层抽象的算法。 深度学习是机器学习中一种基于对数据进行表征学习的算法,至今已有数种深度学习框架,如卷积神经网络和深度置信网络和递归神经网络等已被应用在计算机视觉、语音识别、自然语言处理、音频识别与生物信息学等领域并获取了极好的效果。

激活函数技术

在 计算网络中, 一个节点的激活函数定义了该节点在给定的输入或输入的集合下的输出。标准的计算机芯片电路可以看作是根据输入得到"开"(1)或"关"(0)输出的数字网络激活函数。这与神经网络中的线性感知机的行为类似。 一种函数(例如 ReLU 或 S 型函数),用于对上一层的所有输入求加权和,然后生成一个输出值(通常为非线性值),并将其传递给下一层。

权重技术

线性模型中特征的系数,或深度网络中的边。训练线性模型的目标是确定每个特征的理想权重。如果权重为 0,则相应的特征对模型来说没有任何贡献。

交叉熵技术

交叉熵(Cross Entropy)是Loss函数的一种(也称为损失函数或代价函数),用于描述模型预测值与真实值的差距大小

参数技术

在数学和统计学裡,参数(英语:parameter)是使用通用变量来建立函数和变量之间关系(当这种关系很难用方程来阐述时)的一个数量。

损失函数技术

在数学优化,统计学,计量经济学,决策理论,机器学习和计算神经科学等领域,损失函数或成本函数是将一或多个变量的一个事件或值映射为可以直观地表示某种与之相关“成本”的实数的函数。

TensorFlow技术

TensorFlow是一个开源软件库,用于各种感知和语言理解任务的机器学习。目前被50个团队用于研究和生产许多Google商业产品,如语音识别、Gmail、Google 相册和搜索,其中许多产品曾使用过其前任软件DistBelief。

张量技术

张量是一个可用来表示在一些矢量、标量和其他张量之间的线性关系的多线性函数,这些线性关系的基本例子有内积、外积、线性映射以及笛卡儿积。其坐标在 维空间内,有 个分量的一种量,其中每个分量都是坐标的函数,而在坐标变换时,这些分量也依照某些规则作线性变换。称为该张量的秩或阶(与矩阵的秩和阶均无关系)。 在数学里,张量是一种几何实体,或者说广义上的“数量”。张量概念包括标量、矢量和线性算子。张量可以用坐标系统来表达,记作标量的数组,但它是定义为“不依赖于参照系的选择的”。张量在物理和工程学中很重要。例如在扩散张量成像中,表达器官对于水的在各个方向的微分透性的张量可以用来产生大脑的扫描图。工程上最重要的例子可能就是应力张量和应变张量了,它们都是二阶张量,对于一般线性材料他们之间的关系由一个四阶弹性张量来决定。

神经网络技术

(人工)神经网络是一种起源于 20 世纪 50 年代的监督式机器学习模型,那时候研究者构想了「感知器(perceptron)」的想法。这一领域的研究者通常被称为「联结主义者(Connectionist)」,因为这种模型模拟了人脑的功能。神经网络模型通常是通过反向传播算法应用梯度下降训练的。目前神经网络有两大主要类型,它们都是前馈神经网络:卷积神经网络(CNN)和循环神经网络(RNN),其中 RNN 又包含长短期记忆(LSTM)、门控循环单元(GRU)等等。深度学习是一种主要应用于神经网络帮助其取得更好结果的技术。尽管神经网络主要用于监督学习,但也有一些为无监督学习设计的变体,比如自动编码器和生成对抗网络(GAN)。

梯度下降技术

梯度下降是用于查找函数最小值的一阶迭代优化算法。 要使用梯度下降找到函数的局部最小值,可以采用与当前点的函数梯度(或近似梯度)的负值成比例的步骤。 如果采取的步骤与梯度的正值成比例,则接近该函数的局部最大值,被称为梯度上升。

准确率技术

分类模型的正确预测所占的比例。在多类别分类中,准确率的定义为:正确的预测数/样本总数。 在二元分类中,准确率的定义为:(真正例数+真负例数)/样本总数

目标函数技术

目标函数f(x)就是用设计变量来表示的所追求的目标形式,所以目标函数就是设计变量的函数,是一个标量。从工程意义讲,目标函数是系统的性能标准,比如,一个结构的最轻重量、最低造价、最合理形式;一件产品的最短生产时间、最小能量消耗;一个实验的最佳配方等等,建立目标函数的过程就是寻找设计变量与目标的关系的过程,目标函数和设计变量的关系可用曲线、曲面或超曲面表示。

分类问题技术

分类问题是数据挖掘处理的一个重要组成部分,在机器学习领域,分类问题通常被认为属于监督式学习(supervised learning),也就是说,分类问题的目标是根据已知样本的某些特征,判断一个新的样本属于哪种已知的样本类。根据类别的数量还可以进一步将分类问题划分为二元分类(binary classification)和多元分类(multiclass classification)。

链式法则技术

是求复合函数导数的一个法则, 是微积分中最重要的法则之一。

优化器技术

优化器基类提供了计算梯度loss的方法,并可以将梯度应用于变量。优化器里包含了实现了经典的优化算法,如梯度下降和Adagrad。 优化器是提供了一个可以使用各种优化算法的接口,可以让用户直接调用一些经典的优化算法,如梯度下降法等等。优化器(optimizers)类的基类。这个类定义了在训练模型的时候添加一个操作的API。用户基本上不会直接使用这个类,但是你会用到他的子类比如GradientDescentOptimizer, AdagradOptimizer, MomentumOptimizer(tensorflow下的优化器包)等等这些算法。

推荐文章
暂无评论
暂无评论~