反向传播算法,手动实现前馈神经网络

其实没那么难的,就是讲人话的资料太难找,知乎和CSDN把我看自闭了。

反向传播部分推荐一下Neural Network and Deep Learning第二章How the backpropagation algorithm works,写得真的很不错,应该很容易找到对应的中文版pdf。

反向传播算法

理解

很多讲反向传播算法的文章一上来就开始说链式法则,然后就是一大堆符号下标的公式推导。的确链式法则是反向传播算法的核心,只要能理解利用链式法则的推导过程基本等同于理解了反向传播算法。但这种方法是有门槛的,尤其是对于我这种对导数、梯度等概念理解不够深刻的人来说,一旦推不下去很容易就陷入一种困境。

所以,先给出几个直观的理解,不一定准确严谨,只是我自己的一些感性理解:

  1. 什么叫反向传播:我们用损失函数度量网络模型结果的好坏,但是损失函数得到的值是一个标量,或者说这个值只停留在网络中最后输出层的那一个节点上。我们需要先把这个标量误差反向传播到网络中的每一个节点上,如何传播呢?能作为传播载体的只能是网络本身,也就是层与层之间的权重矩阵。误差也一定附着在神经元上,每个神经元都有一个对应的误差。

  2. 怎么理解误差:误差按照输出层$L\rightarrow$输入层$1$的顺序反向传播,但是当前层的误差应当理解成输入层$1\rightarrow$当前层$l$在传播过程中产生的误差。所以反向传播中的反向指的是计算方式上的反向,实际上误差应当是在正向传播过程中造成的,这里正向和反向似乎很容易混淆。

  3. 误差和偏导数的关系:既然认识到误差是正向传播造成的,那么就可以把某个节点上的误差理解成多个上一层节点误差的结合。求偏导就是为了找到每个上一层节点对这个误差的贡献,说白了就是分锅,看看在造成误差这个事情上,上层节点中每个节点的锅分别有多大。

    举个例子,节点$z$的误差为$\delta(z)=0a_1+5a_2+1000a_3$,把$a_1,a_2,a_3$是$z$的三个上层节点的输出,那么三个节点中,误差主要由$a_3$对应的节点造成,与$a_1$对应的节点完全无关。所以在反向传播中,此时$a_1$对应的节点传播过去的误差为0,$a_3$对应的节点传播过去的误差最大。$\frac{\partial z}{\partial a_1}$和$\frac{\partial z}{\partial a_3}$可以刻画这个大小。

  4. 理解反向传播是一个递推过程:现在回到输出层$L\rightarrow$输入层$1$的反向传播过程中,在反向传播计算得到当前层每个神经元的误差后,先前计算过的层就不再需要了,因为所有信息都被记录在了误差这个状态里。反向传播的过程中只需要关注当前层$l$和上一层$l-1$这两层神经元,以及两层之间的权重矩阵,这是一个递推的过程。

  5. 如何更新权重:现在问题被限制在两层之间,在当前层$l$的误差$\delta^l$的基础上,求误差关于权重的偏导数,并根据偏导数调整权重即可,类似于第三点举的例子$\delta(z)=0a_1+5a_2+1000a_3$,只不过此时$0、5、1000$不再是常数,而是被替换成$w_1,w_2,w_3$,只要根据$\frac{\partial \delta(z)}{\partial w}$使$w$沿梯度方向下降。

感性理解之后还是要开始做推导,毕竟核心还是链式法则的应用。整个过程除了链式求导,还要对几种偏导数的结果形式有了解,比如标量对向量求导、标量对矩阵、向量对向量求导的结果分别是向量、矩阵、矩阵。了解这些结果的布局形式对推导能否顺利进行来说是很重要的。

符号

  • $w^l:$第$l$层的权重矩阵,这里用$w^l_{jk}$表示从$l-1$层的第$k$个神经元到$l$层的第$j$个神经元的连接上的权重

    image-20220321012346133

  • $b^l:$第$l$层的偏置向量

  • $\sigma(·):$激活函数

  • $a^l:$第$l$层神经元的激活值,有递推关系:

  • $z^l:$第$l$层神经元的中间量或者叫带权输入

    需要特别注意是$a^{l-1}_k\rightarrow z^l_j$对应的权重是$w^{l-1}_{jk}$而不是$w^{l-1}_{kj}$,这可能有点反直觉,但可能只有这样才能符合列向量的表示。

    也就是式子应写成$Z=WA+B$的形式,其中$Z,A,B$都是列向量,否则如果只能写成$Z=AW+B$,那么$Z,A,B$都是行向量。这个区别很可能会在代码实现的过程中造成困惑,所以提前说明。

    矩阵形式:

  • $C:$代价函数,由输出层的激活值向量$a^L$计算得出,例如:

  • $\delta^l_j:$定义在$l$层的第$j^{th}$个神经元上的误差:

    为什么可以启发式认为$\frac{\partial C}{\partial z^l_j}$是神经元误差的度量呢,假设我们可以对$z^l_j$进行一个很小的扰动,使神经元输出由$\sigma(z^l_j)$变成$\sigma(z^l_j+\Delta z^l_j)$,那么这个扰动会向后传播,最终导致整个代价产生$\frac{\partial C}{\partial z^l_j}\Delta z^l_j$的改变。那么当$\frac{\partial C}{\partial z^l_j}$有一个很大的值(无论正负),我们可以调整这个扰动$\Delta z^l_j$的符号来降低代价;相反如果$\frac{\partial C}{\partial z^l_j}$接近0,很难通过扰动来改善代价,这时候神经元已经接近最优了。这是一种启发式的认识,表明$\frac{\partial C}{\partial z^l_j}$是神经元的误差的度量。

反向传播的四个基本方程式和证明

1. 输出层的误差$\delta^L_j$:

矩阵形式:

其中$\odot$表示Hadamard乘积,表示两个维度相同的向量对应位置相乘。

证明:由定义$\delta_j^L=\frac{\partial C}{\partial z^l_j}$,由链式法则,如果函数和参数之间有多条路径,应将多条路径上的导数进行相加得到最终的梯度,即

当$k\ne j$时$\frac{\partial a^L_k}{\partial z^L_j}=0$($a^L_i$只和$z^L_i$有关),所以

2. 使用下一层的误差$\delta^{l+1}$来表示当前层的误差$\delta^l:$

理解:误差沿着网络反向移动。

证明

$z^{l+1}_k$是第$l+1$层的带权输入:

所以$z^{l+1}_k$对$z^l_j$求导结果:

代入上式得到:

从分量形式改写成矩阵形式,得到结论:

3. 代价函数$\delta^l_j$关于网络中任意偏置$\frac{\partial C}{\partial b^l_j}$改变率:

证明:

由链式法则:

和上面一样,由于:

所以

代入上式得到结论:

4. 代价函数$\delta^l_j$关于任何⼀个权重$\frac{\partial C}{\partial w^l_{jk}}$的改变率:

证明:

这里符号会复杂一点,但问题的本质是不变的,只是更加仔细地使用链式法则。

由链式法则:

由于:

所以$z^l_p$对$w^l_{jk}$求偏导:

代入上式得到结论:

矩阵形式:

手动实现前馈神经网络

手写一个简单的MLP网络,包括ReLU、Softmax、Log三种激活函数,损失函数采用负对数似然函数。

(Softmax+Log+NLLLoss等价于CrossEntropy)

整体结构:$FFN1\rightarrow RELU\rightarrow FFN2\rightarrow Softmax\rightarrow Log\rightarrow NLLLoss$

在开始实现之前,先指出上述理论与实际实现中的差异

  • 理论中,每个神经元一定由$z_i,a_i$两个部分组成,分别对应了神经元的带权输入和激活后的输出。其中带权输入是多对一,由多个结果带权求和得到,对应现在的全连接层;激活输出是一对一的,每个$a_i$由唯一的$z_i$决定,通常对应现在说的激活函数。

    image-20220321195228358

    从而有了这个误差传播的公式,其中$\odot\sigma’(z^l)$对应激活函数的误差传播,$((w^{l+1})^T\delta^{(l+1)})$对应带权求和部分的误差传播:

    但是实际的网络中并不一定是一层全连接层、一层激活层这样搭建的,实际上也没有必要把全连接层和激活层绑定在一起,完全可以分开传播

  • 从实际的角度看,一些被称为“激活函数”的函数,一部分和理论中一样,是一对一的传播,比如ReLU、Log。但也有一部分虽然常被称为“激活函数”,但实际上是多对一的传播,比如Softmax,$y_i=\frac{e^{x_i}}{\sum\limits_{j=1}^n e^{x_j}}$,$y_i$由$x_1,\cdots,x_n$共同决定,这导致softmax层在反向传播的过程中其实更类似于“带权求和”的传播方式,即先求出梯度矩阵,再把误差通过这个梯度矩阵传播到上一层

  • 理论中向量都是以列向量的形式给出的,但实际中可能会以行向量的形式给出,这可能会导致一些困惑,并需要通过一些转置来达到同样的计算效果。

  • 理论中只考虑了利用一个样本计算误差并反向传播、更新参数。实际情况下需要同时更新一个批量的样本,这同样需要以矩阵形式实现。

  • 计算带权求和时,可以把偏置并入权重W计算。

求导

这里从浅层往深层算,把每一层都作为一个向量函数

NLLLoss

函数将向量转化为标量,对应梯度是一个向量。

$label_i=I[i是真实标签]$

矩阵形式:

Log

函数将向量转化为向量,对应梯度是一个矩阵。

矩阵形式:

实现时,如果用Hadamard乘积来表示。因为梯度是一个对角矩阵,所以左乘这个梯度矩阵相当于对倒数向量作Hadamard乘积。

Softmax

函数将向量转化为向量,对应梯度是一个矩阵。

如果令$y_i=f_3(x_i)$,那么梯度也可以写成

矩阵形式:

FFN

函数将向量转化为向量,对应梯度是一个矩阵。

形式比较简单所以直接对矩阵求导:

ReLU

用矩阵形式表示比较麻烦,直接用Hadamard乘积的方式传播即可。

python代码实现

按照通常的习惯,输入矩阵的shape是(batch_size, input_size),第一维表示批量大小,第二维表示输入向量,同样对应的输出矩阵的shape是(batch_size, output_size)。所以我们给出的输入和输出向量实际上是行向量。

在理论中一个$n\rightarrow m$的权重矩阵维度应当是$(m, n)$的,但在实现中也对这个矩阵进行了转置,变为$(n,m)$。类似的,几乎所有矩阵需要经过转置处理。目前来说我还是没有找到一个很好的方法去理解各种转置之间的关系,很容易对计算过程中各个结果的维度感到茫然。目前的解决方法是,干脆在过程中不要过度考虑矩阵和向量的维度形式,把注意力放在理解结果的意义上,至于维度就放在最后再去考虑,只要每一步都有明确的意义,调整维度、转置等操作只是一个计算过程。

总之需要比较小心地处理这些转置,但好在除了转置之外也没有额外的问题需要解决了。

import numpy as np

class Matmul:
    def __init__(self):
        self.mem = {}

    def forward(self, x, W):
        h = np.matmul(x, W)
        self.mem = {'x': x, 'W': W}
        return h

    def backward(self, grad_y):
        '''
        x: shape(N, d)
        w: shape(d, d')
        grad_y: shape(N, d')
        '''
        x = self.mem['x']
        W = self.mem['W']

        grad_x = np.matmul(grad_y, W.T)  # shape(N, b)
        grad_W = np.matmul(x.T, grad_y)
        return grad_x, grad_W


class Relu:
    def __init__(self):
        self.mem = {}

    def forward(self, x):
        self.mem['x'] = x
        return np.where(x > 0, x, np.zeros_like(x))

    def backward(self, grad_y):
        '''
        grad_y: same shape as x
        '''
        x = self.mem['x']
        return (x > 0).astype(np.float32) * grad_y


class Softmax:
    '''
    softmax over last dimention
    '''

    def __init__(self):
        self.epsilon = 1.e-8
        self.mem = {}

    def forward(self, x):
        '''
        x: shape(N, c)
        '''
        x_exp = np.exp(x)
        partition = np.sum(x_exp, axis=1, keepdims=True)
        out = x_exp / (partition + self.epsilon)
        #print(x_exp[:3, :3], out[:3, :3])

        self.mem['out'] = out
        self.mem['x_exp'] = x_exp
        return out

    def backward(self, grad_y):
        '''
        grad_y: same shape as x
        '''
        s = self.mem['out']
        sisj = np.matmul(np.expand_dims(s, axis=2), np.expand_dims(s, axis=1))  # (N, c, c)
        g_y_exp = np.expand_dims(grad_y, axis=1)
        tmp = np.matmul(g_y_exp, sisj)  # (N, 1, c)
        tmp = np.squeeze(tmp, axis=1)
        tmp = -tmp + grad_y * s
        return tmp


class Log:
    '''
    softmax over last dimention
    '''

    def __init__(self):
        self.epsilon = 1e-12
        self.mem = {}

    def forward(self, x):
        '''
        x: shape(N, c)
        '''
        out = np.log(x + self.epsilon)

        self.mem['x'] = x
        return out

    def backward(self, grad_y):
        '''
        grad_y: same shape as x
        '''
        x = self.mem['x']

        return 1. / (x + 1e-12) * grad_y


class Model_NP:
    def __init__(self, num_inputs, num_outputs, num_hiddens = 100, lr = 1.e-5, lambda1 = 0.5):
        self.W1 = np.random.normal(size=[num_inputs + 1, num_hiddens])
        self.W2 = np.random.normal(size=[num_hiddens, num_outputs])

        self.mul_h1 = Matmul()
        self.mul_h2 = Matmul()
        self.relu = Relu()
        self.softmax = Softmax()
        self.log = Log()

        self.h2_log = None
        self.lr = lr
        self.lambda1 = lambda1
        self.num_hiddens = num_hiddens

    def forward(self, x):

        # ==========
        # todo '''请搭建一个MLP前馈神经网络 补全它的前向传播 MLP结构为FFN --> RELU --> FFN --> Softmax'''
        # ==========

        n_sample = x.shape[0]
        out = x.reshape((n_sample, -1))
        ones = np.ones((n_sample, 1))
        out = np.concatenate((out, ones), axis=1) # (60000, 785)

        out = self.mul_h1.forward(out, self.W1) # (60000, 100)
        out = self.relu.forward(out) # (60000, 100)
        out = self.mul_h2.forward(out, self.W2) # (60000, 10)
        out = self.softmax.forward(out) # (60000, 10)
        out = self.log.forward(out) # (60000, 10)
        self.h2_log = out
        return out

    def backward(self, label):

        # ==========
        # todo '''补全该前馈神经网络的后向传播算法'''
        # ==========
        back = -label # (60000, 10)
        back = self.log.backward(back) # (60000, 10)
        back = self.softmax.backward(back) # (60000, 10)
        back, self.grad_w2 = self.mul_h2.backward(back)
        back = self.relu.backward(back)
        back, self.grad_w1 = self.mul_h1.backward(back)

    def update(self):

        # ==========
        # todo '''更新该前馈神经网络的参数'''
        # ==========
        self.W1 += -self.grad_w1 * self.lr
        self.W2 += -self.grad_w2 * self.lr

if __name__ == '__main__':
    model = Model_NP()