吴恩达深度学习课程作业记录 - 训练深度神经网络

零、前言

本文是基于吴恩达老师的深度学习课程系列中第一门课所写,以最终实现手撸一份神经网络为作业而成。本文只为写下在实现网络过程中,自己感到过困惑的地方的总结,未覆盖实现全连接层神经网络的所有知识细节,如需更多深入了解,可以直达课程笔记。

深度学习笔记-目录

由于吴恩达老师的课程作业中大部分框架都已经搭好了,只需自己实现部分内容,导致忽略框架部分的实现,因此重新手写了所有代码(仍保留了原函数接口)。由于难点在于训练神经网络,因此只记录了与训练神经网络相关的步骤,使用神经网络(计算误差、预测)等部分未记录在此。

一、基础

使用 numpy 进行运算
  • 向量化实现 Relu 函数
import numpy as np
def relu(Z):
  return np.maximum(Z, 0)

使用 numpy.maximum函数,将两个数组传入其中,函数对每个索引上位置进行比较并取最大值。而 numpy.max是另外一个完全不同的函数,传入一个数组,它返回的是这个数组(或者指定某一维)的最大值。

  • 向量化实现 Sigmoid 函数
def sigmoid(Z):
  return 1 / (1+np.exp(-Z))

使用 numpy.exp获取 e x e^x ex 函数的值。

  • 向量化实现 Relu 函数的导数
def relu_backward(Z):
  result = np.array(Z.shape)
  result[Z>0] = 1
  result[Z<=0] = 0
  return result

numpy 中,条件索引不仅可以用于选择数组中的元素, 还能用于给满足特定条件的元素赋值。虽然最终并没有直接用到这里实现的 relu_backward方法,但会用到这里的 numpy条件索引赋值语法。

二、训练神经网络

训练神经网络可以细分成五个步骤:

  1. 初始化神经网络参数 initialize_parameters

参数存储在一个字典中,键使用字符串 W0, b0… 来表示每层参数项,其中需要重点搞清楚的是层数和索引:

神经网络参数说明

def initialize_parameters(layer_dims):
    '''
    :param layer_dims: 表示网络中每层的维度,从输入层 X 开始,到中间隐藏层,一直到最后输出层 Y
    :return: 中间层的参数 W 和 b
    '''
    L = len(layer_dims)
    parameters = {}
    for i in range(1, L):
        parameters["W" + str(i)] = np.random.randn(layer_dims[i], layer_dims[i-1]) / np.sqrt(layer_dims[i-1])
        parameters["b" + str(i)] = np.zeros((layer_dims[i], 1))
    return parameters

将神经网络的参数放到一个名为 parameters的字典中,键值为字符串类型,例如第一层网络的参数 W1,其设置方式为 parameters["W1"] = np.random.randn(...),通过使用这种字典可以省去变量的存储。

  1. 前向传播 L_model_forward

前向传播包含线性传播和激活,线性函数的结果被放入缓存中,同时激活函数的结果也会被放到缓存中,以便后续反向传播时直接使用。(为什么需要将结果放到缓存中,可以通过推导 sigmoidrelu 的导数,可以看到导数值均和原来的输入相关,因此将前向传播的值缓存下来,以便求导时使用)。

s i g m o i d ′ ( x ) = x ( 1 − x ) sigmoid'(x) =x(1-x) sigmoid(x)=x(1x)

r e l u ′ ( x ) = { 1 if  x > 0 , 0 if  x ≤ 0. relu'(x) = \begin{cases} 1 & \text{if } x > 0, \\ 0 & \text{if } x \leq 0. \end{cases} relu(x)={10if x>0,if x0.

L 层神经网络的前 L-1 层均为 relu 层,第 L 层为 sigmoid 层。

def linear_forward(A, W, b):
    Z = np.dot(W, A) + b
    cache = (A, W, b)
    return Z, cache

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

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

def linear_forward_activation(A, W, b, activation):
    Z, linear_cache = linear_forward(A, W, b)
    if activation == "relu":
        A, activation_cache = relu(Z)
    elif activation == "sigmoid":
        A, activation_cache = sigmoid(Z)
        
    return A, (linear_cache, activation_cache)

def L_model_forward(X, parameters):
    # 这里的 L 表示除了输入层之外的所有层数
    L = len(parameters) // 2
    caches = []
    A = X
    
    for i in range(1, L):
        W = parameters["W" + str(i)]
        b = parameters["b" + str(i)]
        A, cache = linear_forward_activation(A, W, b, "relu")
        caches.append(cache)
        
    W = parameters["W" + str(L)]
    b = parameters["b" + str(L)]
    AL, cache = linear_forward_activation(A, W, b, "sigmoid")
    caches.append(cache)
    return AL, caches

代码中需要注意的两个细节:

  • 使用 parameters 来获取神经网络层数,由于 parameters 包含的是所有层的 Wb 参数,且每层中 Wb 均只有一个,因此通过将 paramenters 的长度整除以 2,即可得到神经网络的层数 L(python 中整除是以 //双斜杠表示)。

  • range 函数的取值从 1 到 L-1,而非从 0 开始,因为 Wb 参数没有第 0 层。

  1. 计算损失函数 compute_cost

计算损失函数的目的在于监督函数的学习走向,这一步并没有真的向神经网络输出真正的反馈。

def compute_cost(AL, Y):
    m = Y.shape[1]
    cost = - np.sum((Y * np.log(AL) + (1-Y) * np.log(1-AL))) / m
    cost = np.squeeze(cost)
    return cost
  1. 反向传播 L_model_backward

最难的部分在于反向传播,主要困难在于理解导数是如何传播的。

def relu_backward(dA, cache):
    # print("cache shape " + str(cache.shape))
    # print("dA shape " + str(dA.shape))
    Z = cache
    result = np.array(dA, copy=True)
    result[Z < 0] = 0
    assert result.shape == dA.shape
    return result

def sigmoid_backward(dA, cache):
    Z = cache
    Y = 1/(1+np.exp(-Z))
    result = dA * Y * (1-Y)
    assert result.shape == dA.shape
    return result

def linear_activation_backward(dA, cache, activation):
    linear_cache, activation_cache = cache
    if activation == "relu":
        dZ = relu_backward(dA, activation_cache)
        dA_prev, dW, db = linear_backward(dZ, linear_cache)
    elif activation == "sigmoid":
        dZ = sigmoid_backward(dA, activation_cache)
        dA_prev, dW, db = linear_backward(dZ, linear_cache)

    # print("linear_activation_backward db shape " + str(db.shape))
    return dA_prev, dW, db

def linear_backward(dZ, cache):
    m = dZ.shape[1]
    A_prev, W, b = cache
    dW = np.dot(dZ, A_prev.T) / m
    db = (np.sum(dZ, axis=1) / m).reshape(-1, 1)
    dA_prev = np.dot(W.T, dZ)
    return dA_prev, dW, db


def L_model_backward(AL, Y, caches):
    L = len(caches)
    dAL = -np.divide(Y, AL) + np.divide(1-Y,1-AL)
    
    grads = {}
    cache = caches[L-1]
    dA_prev, dW, db = linear_activation_backward(dAL, cache, "sigmoid")
    grads["dW" + str(L)] = dW
    grads["db" + str(L)] = db
    for l in reversed(range(1, L)):
        cache = caches[l-1]
        dA_prev, dW, db = linear_activation_backward(dA_prev, cache, "relu")
        grads["dW" + str(l)] = dW
        grads["db" + str(l)] = db
    return grads

有一些需要注意的地方:

  • 如何理解 dAL 这个变量,首先需要知道定义的损失函数: J = − y l o g y ^ − ( 1 − y ) l o g ( 1 − y ^ ) J =-ylog\widehat{y}-(1-y)log(1-\widehat{y}) J=ylogy (1y)log(1y ),其中自变量为 y ^ \widehat{y} y ,也即输出值 AL,因此在求导后,得到的是这个函数在当前 y ^ = d A L \widehat{y}=dAL y =dAL 时的导数,这个导数代表了使得 J J J 下降最快的方向。(直观一点的理解,比如我们有一条轨道,轨道是有弧度的,轨道上有一个小球,此时我们需要给这个小球一个力,力的方向可以是任意的,那么如何让这个小球前进效率最高呢?就是沿着当前小球所处轨道的切线方向施加力,上面导数就是这个切线方向,而学习效率就是我们往这个方向上前进的步长)。在找到方向后,我们需要将这个方向分解到各个参数上(例如公司在找到战略方向后,会将战略任务分发到每个下级,让每个人都往一个方向发力),因此这里的 dAL就是帮助我们找到了一个使得损失函数下降的大方向,我们需要将它分解成每个参数的小方向 dW(L), db(L), dW(L-1), db(L-1)dW(1), db(1)

  • 计算 dAL,这是最后一层的输出 AL 与实际 Y 值的交叉熵(损失)的导数。这里没有将所有损失加起来,而是每个样本都有自身的导数。

  • 在计算 dW(l), 和 db(l) 时将所有样本的导数进行平均(这里的平均使用了向量化计算)

  • relu_backward 函数中,正常情况下导数计算公式为 d A ∗ r e l u ′ ( x ) dA * relu'(x) dArelu(x),由于 relu 的导数在 x > 0 时为 1, 在 x < 0 时为 0,因此这里直接使用了一个 dA 数组的拷贝,并使用 dA < 0 构建条件索引,并使用条件索引赋值 dA[dA<0] = 0将所有小于 0 的值变成 0,从而节省乘法的步骤。

  • linear_backward函数中,计算 db 时将结果进行了 reshape(-1, 1)操作。原因是在 np.sum(xxx, axis=1)函数计算完之后得到的是一个一维数组,输出其 shape 可以发现是 (xx,)这种格式,而非我们预期中的 (xx,1)。前者在进行广播计算的时候会出现一些意想不到的情况,例如在后续 update_parameters中,将 (xx,)和一个 (xx, 1)类型的值进行加减运算时,会得到一个 (xx,xx)类型的数组。(这是因为 (xx,) 类型的数组会在行上进行复制,而 (xx,1) 的数组会在列上进行复制,从而使得两者的维度保持一致)

  1. 更新参数 update_parameters
def update_parameters(parameters, grads, learning_rate):
    L = len(parameters) // 2
    for l in range(1, L+1):
        before_shape = parameters["b" + str(l)].shape
        parameters["W" + str(l)] = parameters["W" + str(l)] - grads["dW" + str(l)] * learning_rate
        parameters["b" + str(l)] = parameters["b" + str(l)] - grads["db" + str(l)] * learning_rate
        after_shape = parameters["b" + str(l)].shape
        assert before_shape == after_shape

这个部分的更新就是将每层的梯度 dW(l)db(l)更新到实际参数 W(l)b(l)上(这里就是由于踩了上面所说 b - learning_rate * db时的坑,因此判断了下参数 b 在更新前后形状均不应该发生变化)。其他需要注意和上面一样,也就是 Wb 参数的索引是从 1 到 L,计算 L 的方式是通过 parameters的长度整除以 2 得到。

三、总结

首先介绍了几个在实现神经网络中的函数时,如何使用 numpy 进行向量化计算。更多复杂的函数和向量化计算还需要更多时间的积累。然后介绍了训练神经网络的五个步骤,每个步骤中都有一些需要特别注意和理解的地方。理解了每个细节之后,应该就能理解神经网络的整个训练过程了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值