深度学习之手撕神经网络代码(基于numpy)

家住魔仙堡 提交于 2019-11-29 00:14:26

声明

1)该文章整理自网上的大牛和机器学习专家无私奉献的资料,具体引用的资料请看参考文献。
2)本文仅供学术交流,非商用。所以每一部分具体的参考资料并没有详细对应。如果某部分不小心侵犯了大家的利益,还望海涵,并联系博主删除。
3)博主才疏学浅,文中如有不当之处,请各位指出,共同进步,谢谢。
4)此属于第一版本,若有错误,还需继续修正与增删。还望大家多多指点。大家都共享一点点,一起为祖国科研的推进添砖加瓦。

0、前言

为什么准备写这个系列呢?这个问题我思考了好久一阵子,主要是基于两个方面:

  • 第一,希望自己能更加认真透彻地去理解深度学习这门艺术,为着自己的目标去努力;
  • 第二,就是希望能分享更好更棒的东西给那些初学者,让他们少走一些坑。😃

前一阵子写了一个初学者必看的 大话卷积神经网络CNN(干货满满),反应还不错,算是有了 正反馈,除此之外还上了CSDN首页的今日推荐,nice!!!
在这里插入图片描述
这是成为博客专家之后认真写的系列——深度学习专题,加油!!!

1、深度学习到底需要什么能力?

深度学习到底需要什么能力?当你认真了解了之后就会发现,光是理论知识是极其匮乏无力的,和所有学习一样,需要实践,所以 coding 的能力是极其重要的。针对这个问题,目前我有两个计划,一个是针对LeetCode进行刷题,另一个就是追求最朴素的方式——手撕神经网络代码。

很多人都说深度学习就是个 黑箱子,也就是黑盒艺术,诚然我相信目前确实没法真的说得清,这个我在 深度学习100问之深度学习的本质 中讨论过这个问题,这里就不说了。但我要说的是,如果你已是一名功力纯厚的深度学习工程师,这么做当然没问题。但大多数人应该和我一样,都是走在学习深度学习的路上。一上来就上框架对于你做项目之类的并没有什么特别不妥之处,但对你理解深度学习的黑箱机制是了无裨益的。

所以在学习深度学习的路上,从最简单的感知机开始写起,一步一步捋清神经网络的结构,以至于激活函数怎么写、采用何种损失函数、前向传播怎么写、后向传播又怎么写,权值如何迭代更新,这些都需要你自己去实现,坚持下去,必然会有不一样的成效。相反,若在一开始就直接调用框架,小的 demo 可以跑起来,糊弄一时,当让你独自解决一个大问题时,你必然会手足无措。

无论你是在什么书,是看 花书,还是在学习 Adrew NG 的 deeplearningai,或者是在 CS231n 课程,对神经网络的基本理论了如指掌的你,一定很想像自己见过的大佬一样手撕代码,亲手用 python 来实现一个网络模型。下面我们就要开始了,在不借助任何深度学习框架的基础上,利用 python 的科学计算库 numpy 由最初级的感知机开始,从零搭建一个神经网络模型

2、神经网络的基本概念

在这里插入图片描述
如上图,是一个 单隐藏层神经网络,我们来就此说一下相关的概念:

  1. 输入层

神经网络中的第一层,它需要输入信号并把输入信号传递到下一层,同时不对输入信号做任何操作,并且没有关联的权重和偏置值。在上图中,对应x1,x2,也就是 input layer

  1. 输出层

神经网络的最后一层,它接收来自最后一个隐藏层的输入。通过这个层,我们可以知道期望的值和期望的范围。在上图这个网络中,输出层 有1个神经元,输出prediction,也就是 output layer

  1. 隐藏层

神经网络输入层与输出层之间的网络结构。一个 隐藏层 是垂直排列的神经元的集合(Representation)。在给出的图像中有1个 隐藏层,这个 隐藏层 有4个神经元(节点),它将自己层的输出值传递给输出层(这块比较拗口,每一个层都有自己的输入和输出,这个是值,不是层,传递的大结构是层)。隐藏层 中的每个神经元都与下一层的每一个神经元有连接,因此我们有一个全连接的 隐藏层

  1. 权重与偏置

权重 表示单元之间连接的强度,也就是w。

  • 权重 决定了输入对输出的影响程度。如果从节点1到节点2的 权重 比较大,意味着神经元1对神经元2的影响比较大。
  • 权重 降低了输入值的重要性,所以当 权重 接近零时意味着更改此输入将几乎不会更改输出,而负 权重 意味着增加此输入会降低输出。

偏置 是神经元的额外输入,当然还有一种叫法,叫做阈值。通俗来说,阈值的高低代表了意愿的强烈,阈值越低就表示越想去,越高就越不想去。

在图中明确写出了上面所讲的四个概念,输入层、隐藏层、输出层、权重和偏置。
在这里插入图片描述

  1. 激活函数

这个概念我们上次讲过,大话卷积神经网络CNN(干货满满),激活函数可以认为是神经网络中的非线性引入者,如果没有非线性,那么再多的层数叠加也不过是一个线性函数,可以用足以简单的 y=wx+by=wx+b 来表示。如下是一些常用的激活函数
在这里插入图片描述

  1. 损失函数

损失函数(loss function) 定义了拟合结果和真实结果之间的差异,作为优化的目标直接关系模型训练的好坏,所以很多研究工作的内容也集中在损失函数的设计优化上。在应用中,损失函数通常作为学习准则与优化问题相联系,即通过最小化损失函数求解和评估模型,在统计学和机器学习中被用于模型的参数估计(parameteric estimation)。

  1. 前向传播

前向传播的过程是向神经网络馈送输入值并得到prediction的输出。当我们将输入值提供给神经网络的第一层(即输入层)时,它没有进行任何操作,而第二层网络(即隐藏层)则是从第一层网络(即输入层)中的输出值并进行乘法,加法和激活函数等一系列操作,然后将得到的值传递给下一层网络,在我们这里就是输出层(如果有多层隐藏层的话,就在后面的隐藏层中执行相同的操作),最后在输出层得到一个输出值 。
在这里插入图片描述
如果后期有时间的话,会在深度学习100问中详细写一写前向传播的,可以期待一下。

  1. 反向传播

BP算法(即反向传播算法)适合于多层神经元网络的一种学习算法,它建立在梯度下降法的基础上。BP网络的输入输出关系实质上是一种映射关系:一个n输入m输出的BP神经网络所完成的功能是从n维欧氏空间向m维欧氏空间中一有限域的连续映射,这一映射具有高度非线性。它的信息处理能力来源于简单非线性函数的多次复合,因此具有很强的函数复现能力。这是BP算法得以应用的基础。

关于理解反向传播,毛遂自荐一下自己的博客——深度学习100问之深入理解Back Propagation(反向传播)
在这里插入图片描述

这里辨析一下两个概念,因为很多初学者容易被这两个概念绕住,一个是前向传播,一个是反向传播。前向传播简单而言就是计算预测输出 y 的过程,而后向传播则是根据预测值和实际值之间的误差,不断往回推更新权值和偏置的过程。

3、神经网络最简单的结构单元:感知机

首先,先来搞一下神经网络最简单的结构单元:感知机,对猫识别问题进行分析。

感知机就是一个最简单的线性分类模型,不严谨的说,就是一个 y=wx+b。

在这里插入图片描述
如图所示,是一个比较熟悉的分类模型——猫的识别,详细的理论这里就不多赘述了。现在来想一下一个完整的神经网络模型需要什么,这个必须心中有数,其实简单来说,就是上面提到的相关概念分分类,通常情况下是:

  1. 构建网络
  2. 初始化参数
  3. 迭代优化
  4. 计算损失
  5. 反向传播
  6. 更新参数

4、感知机的编程实现(基于numpy)

有了上面这个思路和进行分析之后,就可以开始编程实现感知机了。当然了,要先说明一下,我们准备实现的是一个最简单的感知机模型,所以不需要做出特别定义,就如图中所示即可。

1)激活函数

首先定义激活函数,激活函数有很多种,上面也给出常用的激活函数,这里就不多说了,直接使用大名鼎鼎的 sigmoid 函数,公式定义如下:
在这里插入图片描述

代码如下:

import numpy as np


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

2)参数初始化

模型参数主要包括权值 w 和偏置 b,这也是神经网络学习过程中需要学习的东西。关于参数初始化成多少这件事,其实就是一个经验问题,我暂时不知道什么相关的理论,如果你有的话,可以评论告诉我。接着继续利用 numpy 对参数进行初始化,代码如下:

def initilize_with_zeros(dim):
    w = np.zeros((dim, 1))
    b = 0.0
    #assert(w.shape == (dim, 1))
    #assert(isinstance(b, float) or isinstance(b, int))
    return w, b

接下来就要进入模型的主体部分,这个部分中包括迭代优化、计算损失、反向传播和更新参数这四部分,这也是神经网络训练过程中每一次需要迭代的部分。
在这里插入图片描述


3)前向传播

前向传播函数的预测值 y 为模型从输入到经过激活函数处理后的输出的结果。损失函数采用交叉熵损失,公式如下:
在这里插入图片描述
利用 numpy 定义如下函数:

def propagate(w, b, X, Y):
    m = X.shape[1]
    A = sigmoid(np.dot(w.T, X) + b)
    cost = -1 / m * np.sum(Y * np.log(A) + (1 - Y) * np.log(1 - A))

    dw = np.dot(X, (A - Y).T) / m
    db = np.sum(A - Y) / m
    # assert(dw.shape == w.shape)
    # assert(db.dtype == float)
    cost = np.squeeze(cost)
    # assert(cost.shape == ())
    grads = {'dw': dw,
             'db': db}

    return grads, cost

在上面的前向传播函数中,先是通过激活函数直接表示了感知机输出的预测值 A,然后通过定义的交叉熵损失函数计算了损失 cost,最后根据损失函数计算了权值 w 和偏置 b 的梯度 dwdb,将参数梯度结果以字典和损失一起作为函数的输出进行返回,这就是前向传播的编写思路。

有童鞋不懂 dwdb 为什么那么表示的,下面是相关的推导过程,下面贴上自己的手写版本,字迹太草还请见谅。如果你实在不能懂这一块,记住就行,因为可能有的童鞋数学功底不太好,不过这对于我们这一节并不是特别重要。
在这里插入图片描述
其中的sigmoid求导过程如下:
在这里插入图片描述
至于为什么 dw 没有求和号,db 有求和号,个人觉得应该是 dw 是矩阵乘法,理解为可以自动求和,而 db 是矩阵加法,需要手动求和。

4)反向传播

反向传播操作计算每一步的当前损失,然后根据损失对权值进行更新。关于理解反向传播,还是毛遂自荐一下自己的博客——深度学习100问之深入理解Back Propagation(反向传播),和之前一样,还是定义一个函数,代码如下:

def backward_propagation(
		    w, b, X, Y,
		    num_iterations,
		    learning_rate,
		    print_cost=False):
    cost = []
    for i in range(num_iterations):
        grad, cost = propagate(w, b, X, Y)

        dw = grad['dw']
        db = grad['db']

        w = w - learing_rate * dw
        b = b - learning_rate * db
        if i % 100 == 0:
            cost.append(cost)
        if print_cost and i % 100 == 0:
            print("cost after iteration %i: %f" % (i, cost))

    params = {"dw": w,
              "db": b}

    grads = {"dw": dw,
             "db": db}

    return params, grads, costs

在上述函数中,先是建立了一个损失列表 cost,然后将前一步定义的前向传播函数 propagate(w, b, X, Y) 放进去执行迭代操作,计算每一步的当前损失和梯度 grad,利用梯度下降法对权值进行更新,并用字典封装迭代结束时的参数和梯度进行返回 params

5)测试函数

通常一个模型建好之后,还需要对测试数据进行预测,根据预测的结果才能判断模型的好坏,所以也要定义一个预测函数 predict,将模型的概率输出转化为0 / 1值,1就是是,0就是否,也就是判断结果。

def predict(w, b, X):
    m = X.shape[1]
    Y_prediction = np.zeros((1, m))
    w = w.reshape(X.shape[0], 1)

    A = sigmoid(np.dot(w.T, X) + b)
    for i in range(A.shape[1]):
        if A[:, i] > 0.5:
            Y_prediction[:, i] = 1
        else:
            Y_prediction[:, i] = 0

    # assert(Y_prediction.shape == (1, m))
    return Y_prediction

6)封装函数(选看)

到这里整个模型算是写完了,下面这个可以选用,但是定义了这么多函数,调用起来太麻烦,所以致力于要写出 pythonic 的代码的我们,pythonic 是一种代码风格,当然最常用的代码风格还是 PEP8,自己看这个博客——杂谈——Python代码写得丑怎么办?autopep8来帮你,肯定想对这些函数进行一下简单的封装,代码如下:

def model(X_train, Y_train, X_test, Y_test, num_iterations=2000,
          learning_rate=0.5,
          print_cost=False):
    #用零初始化参数
    w, b = initialize_with_zeros(X_train.shape[0])  #梯度下降
    #从字典 "parameters" 中检索参数 w 和 b
    parameters, grads, costs = backward_propagation(
        w, b, X_train, Y_train, num_iterations, learning_rate, print_cost)

    w = parameters["w"]
    b = parameters["b"]  # Predict test/train set examples
    Y_prediction_train = predict(w, b, X_train)
    Y_prediction_test = predict(w, b, X_test)  # Print train/test Errors
    print("train accuracy: {} %".format(100 - np.mean(np.abs(Y_prediction_train - Y_train)) * 100))
    print("test accuracy: {} %".format(100 - np.mean(np.abs(Y_prediction_test - Y_test)) * 100))

    d = {"costs": costs,
         "Y_prediction_test": Y_prediction_test,
         "Y_prediction_train": Y_prediction_train,
         "w": w,
         "b": b,
         "learning_rate": learning_rate,
         "num_iterations": num_iterations}
    return d

这样,一个简易的神经网络——感知机,就用 numpy 写出来了,下面是实现一个单隐层的神经网络。

5、单隐层神经网络的编程实现(基于numpy)

我们在第二章简单地讲过什么是单隐层的神经网络,就是只有一个隐藏层,而含单隐层的神经网络是不一样的,结构如下所示:
在这里插入图片描述
下面按照步骤进行问题分析:

  1. 构建网络
  2. 初始化参数
  3. 迭代优化
  4. 计算损失
  5. 反向传播
  6. 更新参数

1)构建网络

假设 X 为神经网络的输入特征矩阵,y 为标签向量,则网络结构定义如下:

def layer_sizes(X, Y):
    n_x = X.shape[0] #输入层大小
    n_h = 4 #隐藏层大小
    n_y = Y.shape[0] #输出层大小
    return (n_x, n_h, n_y)

其中输入层和输出层的大小分别与 Xyshape 有关,也就是待定的状态,根据输入输出的要求而有所改变,而隐层的大小可由我们手动指定,这里我们指定隐层的大小为4。

2)初始化参数

假设 W1 为输入层到隐层的权重、b1 为输入层到隐层的偏置;W2 为隐层到输出层的权重,b2 为隐层到输出层的偏置。初始化参数的函数如下:

def initialize_parameters(n_x, n_h, n_y):
    W1 = np.random.randn(n_h, n_x)*0.01
    b1 = np.zeros((n_h, 1))
    W2 = np.random.randn(n_y, n_h)*0.01
    b2 = np.zeros((n_y, 1)) 
   
    #assert (W1.shape == (n_h, n_x))    
    #assert (b1.shape == (n_h, 1))    
    #assert (W2.shape == (n_y, n_h))    
    #assert (b2.shape == (n_y, 1))

    parameters = {"W1": W1, 
                  "b1": b1,                 
                  "W2": W2,                  
                  "b2": b2}   
                   
    return parameters

其中对权值的初始化,利用了 numpy 中的生成随机数的模块 np.random.randn,偏置的初始化则是使用了模块 np.zero。通过设置一个字典进行封装,并返回包含初始化参数之后的结果 parameters

3)前向传播

在完成了前两步之后,就开始执行神经网络的训练过程了,而训练的第一步则是执行前向传播计算。

假设隐层的激活函数为 tanh 函数:
在这里插入图片描述
输出层的激活函数为 sigmoid 函数,前面用过的那个激活函数,前向传播的基本原理公式如下:

y=wx+by=wx+b

A=g(y)A=g(y)

则我们的单隐层神经网络的前向传播公式为:
在这里插入图片描述
定义前向传播计算函数,代码如下:

def forward_propagation(X, parameters):
    #从字典 "parameters" 中检索每个参数
    W1 = parameters['W1']
    b1 = parameters['b1']
    W2 = parameters['W2']
    b2 = parameters['b2']    
    #实现正向传播以计算A2(概率)
    Z1 = np.dot(W1, X) + b1
    A1 = np.tanh(Z1)
    Z2 = np.dot(W2, Z1) + b2
    A2 = sigmoid(Z2)    
    #assert(A2.shape == (1, X.shape[1]))

    cache = {"Z1": Z1,                   
             "A1": A1,                   
             "Z2": Z2,                  
             "A2": A2}    

    return A2, cache

从参数初始化结果字典 cache 里取到各自的参数,然后执行一次前向传播计算,将前向传播计算的结果保存到 cache 这个字典中, 其中 A2 为经过 sigmoid 激活函数激活后的输出层的结果。

4)计算损失

前向传播计算完成后,需要确定以当前参数执行计算后的的输出与标签值之间的损失大小,即损失函数。如果采用损失函数交叉熵损失,则公式如下:
在这里插入图片描述
代码如下:

def compute_cost(A2, Y, parameters):
    m = Y.shape[1]  #样例的数量
    #计算交叉熵损失
    logprobs = np.multiply(np.log(A2), Y) + np.multiply(np.log(1 - A2), 1 - Y)
    cost = -1 / m * np.sum(logprobs)
    cost = np.squeeze(cost)     #确保损失函数是我们期望的维度

    #assert(isinstance(cost, float))
    return cost

5)反向传播

当前向传播和当前损失确定之后,就需要继续执行反向传播过程来调整权值了,也就是迭代优化。中间涉及到各个参数的梯度计算,这里给出的是吴恩达深度学习课程中的推导公式,具体如下图所示:
在这里插入图片描述
则根据上述梯度计算公式定义反向传播函数,代码如下:

def backward_propagation(parameters, cache, X, Y):
    m = X.shape[1]
    #首先,从字典 "parameters" 中检索 W1 和 W2
    W1 = parameters['W1']
    W2 = parameters['W2']
    #还可以从字典 "cache" 中检索 A1 和 A2
    A1 = cache['A1']
    A2 = cache['A2']
    #反向传播:计算dW1, db1, dW2, db2
    dZ2 = A2 - Y
    dW2 = 1 / m * np.dot(dZ2, A1.T)
    db2 = 1 / m * np.sum(dZ2, axis=1, keepdims=True)
    dZ1 = np.dot(W2.T, dZ2) * (1 - np.power(A1, 2))
    dW1 = 1 / m * np.dot(dZ1, X.T)
    db1 = 1 / m * np.sum(dZ1, axis=1, keepdims=True)

    grads = {"dW1": dW1,
             "db1": db1,
             "dW2": dW2,
             "db2": db2}
    return grads

将各参数的求导计算结果 dW1db1dW2db2求出来,然后放入字典 grad 进行返回。

注意:在机器学习中,当所学问题有了具体的形式之后,就会转化成数学问题,也就是机器学习就会形式化为一个 求优化 的问题。不论是 梯度下降法随机梯度下降牛顿法拟牛顿法,抑或是 Adam 之类的高级的优化算法,这些都需要花时间掌握去掌握其数学原理,慢慢积累,先掌握最基本的知识,以实现功能从而带来正反馈,不至于劝退。

在这里插入图片描述

6)权值更新

迭代计算的最后一步,就是根据反向传播的结果更新权值了,更新公式如下:
在这里插入图片描述
其中,θ\theta 是要更新的参数,α\alpha 是学习率,JJ 损失函数。

由该公式可以定义权值更新函数,代码如下:

def update_parameters(parameters, grads, learning_rate=1.2):
    #从字典 "parameters" 中检索每个参数
    W1 = parameters['W1']
    b1 = parameters['b1']
    W2 = parameters['W2']
    b2 = parameters['b2']
    #从字典 "grads" 中检索每个梯度
    dW1 = grads['dW1']
    db1 = grads['db1']
    dW2 = grads['dW2']
    db2 = grads['db2']
    #每个参数更新的规则
    W1 -= dW1 * learning_rate
    b1 -= db1 * learning_rate
    W2 -= dW2 * learning_rate
    b2 -= db2 * learning_rate

    parameters = {"W1": W1,
                  "b1": b1,
                  "W2": W2,
                  "b2": b2}
    return parameters

7)封装函数(选看)

这样,前向传播 - 计算损失 - 反向传播 - 权值更新 的神经网络训练过程就完成了,网络学习的过程其实就是这么一个迭代的过程,从而找到使得损失函数最小的最优解。和前面一样,为了更加 pythonic 一点,可以把各个模块组合起来,定义一个神经网络模型:

def nn_model(X, Y, n_h, num_iterations=10000, print_cost=False):
    np.random.seed(3)
    n_x = layer_sizes(X, Y)[0]
    n_y = layer_sizes(X, Y)[2]
    #初始化参数,然后检索 W1, b1, W2, b2。输入:“n_x,n_h,n_y”。outputs=“W1, b1, W2, b2,parameters”
    parameters = initialize_parameters(n_x, n_h, n_y)
    W1 = parameters['W1']
    b1 = parameters['b1']
    W2 = parameters['W2']
    b2 = parameters['b2']
    #循环(梯度下降)
    for i in range(0, num_iterations):
        #正向传播。输入:“X,parameters”。输出:“A2,cache”
        A2, cache = forward_propagation(X, parameters)
        #损失函数。输入:“A2,Y,parameters”。输出:“cost”
        cost = compute_cost(A2, Y, parameters)
        #反向传播。输入:“parameters, cache, X, Y”。输出:“grads”
        grads = backward_propagation(parameters, cache, X, Y)
        #梯度下降更新参数。输入:“parameters, grads”
		#输出:“parameters”
        parameters = update_parameters(parameters, grads, learning_rate=1.2)
        #每1000次迭代打印一次损失
        if print_cost and i % 1000 == 0:
            print("Cost after iteration %i: %f" % (i, cost))

    return parameters

这样,一个含单隐层的神经网络,就用 numpy 写出来了。

参考文章

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!