「这是我参与11月更文挑战的第19天,活动详情查看:2021最后一次更文挑战」。
许多关于神经网络的书中,都会引入各种图的概念,以及并从计算图的角度出发介绍神经网络的原理,引入相当多的分支和运算。但对于简单的神经网络从宏观上,可能可以用一种更简洁的方式理解它的具体运行过程。
假设我们有一个这样的神经网络,它只有一个隐藏层和一个输出层。它们的权值数量,输入输出大小都先不去考虑。 我们从这个神经网络上考虑前向传播与反向传播的具体过程。
flowchart LR
input([输入]) --> linear1[[隐藏层 f]] --> sigmoid1{{激活函数g}}-->linear2[[隐藏层 h]] --> sigmoid2{{激活函数J}}
这里把线性单元和激活函数分开了。出于简单考虑,假设激活函数都使用 sigmoid 函数
那么这其中只有两种元素,线性函数和 sigmoid 函数。 我们先不考虑函数的具体输入。
对于线性函数 ,考虑它的梯度,对于它所在的层,在神经网络中它应该看作是关于 的三元函数,对于其他的层而言,它则应该被看作是一个关于 的一元函数。所以它的梯度分别是对 、 、 求偏导。具体如下:
- 对于 x 有
- 对于 w 则有
- 对于 b 则有
再来看所谓的激活函数 sigmoid ,它是一个一元函数,所以它的偏导数如下
现在我们考虑一下前向传播的过程。
隐藏层:
第一次激活: , 其中 为上述的
输出层: , 其中 为上述的
第二次激活: ,其中 其中 为上述的
这样写是有原因的,这点后续再表。现在我们得到了神经网络的输出,考虑计算一下它的准确程度,这里使用所谓的平方差损失函数,出于便利考虑,这里会添加一个系数。
其中 t 是标签值
假设有误差,那么就需要我们想办法让它的值尽可能地减小,依据的原理就是梯度下降方法,也就是让函数的变量朝让损失函数减小的目标调整。
一个很经典的错误认识是过分关注 x 或者说输入这个部分,然而实际上神经网络中的参数都可以看作是函数中的变量,而输入实际上则是不可变的。也就是中间调整权值 和偏置 以尽量使得损失的值较小。
我们先考虑对输出层的调整,考虑调整它的权值。
梯度下降法告诉我们,数据应该向负梯度的方向移动,学习率 lr 在这里实际上不是重点,这里我们不关心它的值,尽管它相当重要。
一般来说,调整的方式下述过程。
这需要一个偏导数,我们尝试求解这个偏导数,这会设计到链式法则的应用。
前面我们考虑过两种函数的梯度,没考虑的仅仅是损失函数。现在将三个偏导一同考虑一下
损失函数对 J 求导,先前特别设置的系数就是为了偏导更简洁。
这个函数属于比较神奇的一类,一般来说,导数本身和函数是无关的,但有些函数比较特别,它的导数可以通过函数知道,有点类似 ,尽管从数学上来讲它们不应该是有关系的,但是实际上它们却有一定的关系,这可以让我们省下一些计算的开销。
可以发现,对于输出层的权值而言,要调整它依赖前两个偏导的计算。 再来看偏置 b 的调整。公式如下:
其实基本是一样的,向负梯度方向移动。
展开偏导数如下:
可以看到,对于这个一层的参数而言,它们都依赖后续的层提供的一部分值(两个偏导的乘积)。
为了更准确深度的理解,我们再来考虑隐藏层的权值的更新。
同样是遵从梯度下降的原则,也就是需要计算梯度来更新。
注意这里的 w 和上面的 w 不是指同一个。接下来同样是展开偏导式
更具体地展开这里就不写了,它也会是一些上层的偏导乘以他这一层的对 w 的偏导的形式。偏置b 也类似。故不再赘述。
偏导数变得越来越长,但是和前述相同的是,它们的更新依赖于后续层计算的偏导。而这些偏导之间,有相当一部分是重复的计算。而对于这些重复的计算过程,可以通过保留的方式进行避免重复计算。
这也是 bp 算法的精髓,它发现了这些运算之间的公共部分,期望这些公共部分能够被复用,这有些类似动态规划的思想。
还有一个值得注意的点则是,在更新权值的使用实际上,我们使用了 x ,请注意这是指它的前一层的输出。这意味着必须要将输入给本层的数据保留,这也是先前将每一层分开书写的原因。
完成了数学形式上的理解,最后我们再考虑下从代码上实现,我们将忽略具体的数据使用,仅关注前向传播和反向传播的过程。
import numpy as np
Tensor = np.ndarray
X: Tensor
t: Tensor
w1: Tensor
w2: Tensor
b1: Tensor
b2: Tensor
lr = 1e-6
def sigmoid(x: Tensor):
return 1 / (1 + np.exp(-x))
def diff_sigmoid(x: Tensor): # 这里为了简化计算考虑,实际上并非是在 x 处的导数,而是在 x = sig(...) 时对应的导数
return x * (1 - x)
def loss(x: Tensor):
return 1 / 2 * (t - x)
def diff_loss(x: Tensor): # 误差在x的导数
return t - x # t 是标签
for epoch in range(10):
f = np.dot(X, w1) + 1 # 隐藏层
g = sigmoid(f) # 隐藏层经过激活函数
h = np.dot(g, w2) # 输出层
J = sigmoid(h) # 输出
# 考虑误差对输出的偏导
diff_L_to_j = diff_loss(J)
# 激活函数部分的导数, 这里依赖了 sigmoid 激活运算后的结果 J ,正常应该是依赖 h
diff_J_to_h = diff_sigmoid(J) # (1-sigmoid(h)) * sigmoid(h) 但是这样开销大
# 合并一部分,相当于传播梯度
diff_L_to_h = diff_L_to_j * diff_J_to_h
# 输出层对w的偏导,依赖输入的 g
diff_h_to_w = g
# 输出层对 b的偏导,实际上可以不用算
diff_h_to_b = 1
# 计算 h 对前一层的导数
diff_h_to_g = w2
# 计算激活函数 g 对前一层的导数 依赖本层输出 g
diff_g_to_f = diff_sigmoid(g)
# 合并一部分,相当于传播梯度
diff_L_to_f = diff_J_to_h * diff_h_to_g * diff_g_to_f
# 计算 f 对 w 的偏导, 依赖输入 X
diff_f_to_w = X
# 计算 f 对 b 的偏导, 实际可以不用计算
diff_f_to_b = 1
# 调整权值
w2 = w2 - lr * diff_L_to_h * diff_h_to_w
b2 = b2 - lr * diff_L_to_h # * diff_h_to_b2
w1 = w1 - lr * diff_L_to_f * diff_f_to_w
b1 = b1 - lr * diff_L_to_f # * diff_f_to_b
复制代码
这里的程序并没有真正意义上进行训练,而是做了一些取舍,没有对数据进行具体化,也没有使用更多的中间变量,所以实际使用的时候可能还需要一定的微调。
从代码中也可以看到,前向运算和反向传播都需要相当一部分的内存空间用于保存中间结果。这也是神经网络的训练对内存有一定要求的原因。