深度学习一大特色就是能够根据给定数据和自己的预判结果进行自我调整,然后让自己的预判结果跟实际数据越来越接近。前面我们研究的损失函数用于判断模型的输出结果与实际结果的"接近层度",反向传播算法就是调整
模型内部参数,然后让模型的输出在损失函数的评判下,使得输出结果与实际数据的"接近层度"越来越大,也就是让损失函数的数值越来越小。
如果你了解微积分,那么你就会了解通过求导来求函数极值。如果一个单变量函数y=f(x),我们想要找到x',使得f(x')是整个函数的最小值,那么基本算法是,先使用任意一点x0,然后计算在点x0处的导数。如果导数值为0,
那么此时x0就是函数的极大值或者是极小值,到底是哪一种情况,我们需要做进一步的判断,但无论如何,x0一定是函数的极值点。如果在x0处求导后结果大于0,那意味着往x0的右边挪动会使得f(x)取值增大,向x0左边挪懂,
就能让f(x)取值减小,由于我们求最小值,因此在这种情况下就需要向x0左边挪动"一小步"。同理如果在x0处求导结果小于0,那意味着向x0左边挪动"一小步"会让函数取值增大,向右边挪动"一小步"就会让f(x)取值减小。

这里的"一小步"到底如何量化也是大模型算法的一个要点,我们后面会详细研究。反向传播算法的目的之一就是确定"向左"还是"向右"挪动。虽然原理与上面的单变量函数相同,但是大模型拥有数千亿乃至万亿参数,因此它
的处理就要比上面的描述复杂得多:

我们看看基于大模型反向传播算法的执行步骤:
1.前向传播。该步骤跟我们前面描述单变量函数类似,由于大模型可以看做是一个多变量函数,因此输入对应的就是一个含有多个变量的向量,通常成为"tensor",通过输入数据得到模型的判断结果:F(X).由于大模型是一层连着一层构成的,因此前向传播时,我们需要逐层计算,上一层的输出会成为下一层的输入,例如当前层属于第l层,那么此时的计算为:

其中a(l-1)是上一层的输出结果,Wl 表示上一层与当前层参数直接的连接参数。例如第l-1层有10个神经元,第l层有20个神经元,l-1层的第一个神经元与l层的神经元都有连接,那么连接参数就是20个,由于l-1层每
个神经元都跟l层所有神经元有连接,于是这些连接参数就可以使用一个10X20的矩阵来表示,这些参数就对应要求导确定调整方向的参数。在计算完当前层的输出结果后,我们还需要使用激活函数对当前输出做一次非线性变化:

对于大模型而言,上面使用的激活函数f通常是GeLU。
当计算传导到最后一层,我们获得最后一层的输出结果后,就能将输出结果和实际要对比的数据传入损失函数进行计算,由此得到模型输出跟实际结果的"差距"。
2.反向传播求导。模型内部可调整的参数,其实就是模型内部一层层神经元前后连接的参数。前面我们看到前后两层神经元之间的连接参数可以使用矩阵来表示,因此我们把模型看做一个函数的话,那么结合内部参数就可以
表达成 L = F(W1, W2...Wn)。反向传播求导的核心原则是链式法则,加深损失L依赖于输出a, 同时a又通过参数w可以调整控制,那么要调整L就需要调整a,但调整a又得调整w,于是根据链式法则就有:

对于大模型而言,这条反向传播的链条非常长。正如夜长梦多,一旦链条长了,出错的概率就很大,后面我们会看到如何使用一系列算法控制这个长链条计算过程中出现的偏差。
我们针对当前第l层来拆解具体的数学步骤:
2.1.计算当前层的输出误差流:如果我们当前位于第l层,由于是反向传播,那么我们就已经知道l+1层反向传回来的梯度数值d^(l+1).从前面前向传播的过程我们知道,第l+1层的输入来自于第l层的输出进过一次激活函数。
因此当前l层的梯度计算就是:

上图中符号"一个圆圈内部一个点"表示两个向量逐元素相乘。这一步叫"将误差通过激活函数反向传播"
2.2.由于两层神经元直接的连接参数就是模型内部要修改的关键参数,因此我们需要针对连接参数W进行修正,对于权重W^l,也就是第l层于第l+1层直接的连接参数,它的修改步骤如下:

每一层除了连接参数外,还有一个针对该层的修正参数b^l,我们也要针对其进行修正:

2.3.我们需要将梯度继续向前传播,也就是从第l层向第l-1成传播,以便修正第l-1层连接的参数:

3.利用反向传播算出来的梯度,使用各种优化器(SGD, Adam)更新参数:

上面描述的算法步骤包含多个数学参数,看起来很乱,我们走一段具体代码来理解上面的算法步骤。我们将使用代码模拟一个两层神经网络,每层只包含两个神经元,然后走一遍向前传播和向后传播的过程,执行上面的算法步骤。首先我们先设计一个拥有两层,每层两个节点的网络:
js
import numpy as np
# ===================== 设置随机种子 =====================
np.random.seed(42)
# ===================== 网络结构与参数 =====================
# 输入特征(2个样本,每个样本2维特征)
X = np.array([[0.5, 0.3], [0.2, 0.8]]) # shape: (2, 2) 批量大小2
Y_true = np.array([[0.8, 0.2], [0.6, 0.4]]) # 真实标签 shape: (2, 2)
# 第一层(输入->隐藏): 2个输入,2个神经元
W1 = np.random.randn(2, 2) * 0.5 # shape (2, 2)
b1 = np.random.randn(2, 1) * 0.5 # shape (2, 1)
# 第二层(隐藏->输出): 2个输入,2个神经元
W2 = np.random.randn(2, 2) * 0.5 # shape (2, 2)
b2 = np.random.randn(2, 1) * 0.5 # shape (2, 1)
# 激活函数:sigmoid
def sigmoid(z):
return 1 / (1 + np.exp(-z))
def sigmoid_derivative(a):
return a * (1 - a)
上面代码对应的网络结构如下:

从上图我们可以看出输入 W^1 = \[0.25,0.32,-0.07, 0.76], b1=-0.12,-0.12; W^2=\[0.79,-0.23,0.38,0.27], b2=-0.23, 0.23. 接下来我们看看如何执行前向传播:
js
print("==================== 前向传播 ====================")
# 输入层激活值
a0 = X.T # shape (2, 2) 每一列是一个样本
# 第一层线性变换
z1 = np.dot(W1, a0) + b1 # shape (2, 2)
a1 = sigmoid(z1) # shape (2, 2)
# 第二层线性变换
z2 = np.dot(W2, a1) + b2 # shape (2, 2)
a2 = sigmoid(z2) # shape (2, 2)
# 损失函数:均方误差 (MSE)
L = np.mean((a2 - Y_true.T) ** 2)
print(f"a0 (输入激活值):\n{a0}\n")
print(f"z1 (隐藏层线性输入):\n{z1}\n")
print(f"a1 (隐藏层激活值):\n{a1}\n")
print(f"z2 (输出层线性输入):\n{z2}\n")
print(f"a2 (输出层激活值):\n{a2}\n")
print(f"L (损失): {L:.6f}\n")
上面代码运行后给出结果如下:
js
==================== 前向传播 ====================
a0 (输入激活值):
[[0.5 0.2]
[0.3 0.8]]
z1 (隐藏层线性输入):
[[-0.01363779 -0.12271099]
[ 0.27330813 0.55691232]]
a1 (隐藏层激活值):
[[0.4965906 0.46936069]
[0.56790487 0.63573781]]
z2 (输出层线性输入):
[[ 0.37831724 0.382845 ]
[-0.19537192 -0.17057832]]
a2 (输出层激活值):
[[0.59346718 0.5945591 ]
[0.45131179 0.45745852]]
L (损失): 0.027286
通过上面代码我们可以看到,a1其实是a0经过第一层神经元与第二层神经元的链路上的参数,在加上链路末尾的调整参数b1后的结果,z1是输入第二层神经元时执行一次激活函数后的结果。我们通过如下图像能比较好的
理解这个过程:

从上图可以看到,输入信号a00 = 0.5,经过第一层第一个节点与第二层第一个节点的链路后,用输入信号乘以链路权重,也就是 0.50.25,然后第一层第二个元素的输入值a01=0.3,这个数值经过第一层第二个元素与第二层第一个元素的链路后,信号乘以链路上的参数,也就是0.3-0.15, 两个输入到第二层第一个元素的信号数值加总再加上第二层第一个神经元的调整参数b00=-0.12,于是得到输入第二层第一个元素的加总信号值为: z10 = 0.5 * 0.25 + 0.3 * -0.15 + -0.12 = 0.125, 这个数值在经过一次激活变成第二层第一个元素的输出信号a10,也就是a10= sigmoid(z10) = 0.479,第二层第二个神经元的输出数值可以此类推。
接下来我们看看反向传播的算法原理。反向传播其实是链式法则求导的具体实现。从前向传播我们可以看到如下的逻辑链条:

从上面路径可以看到,最终损失函数的计算中,链路参数W000发挥了作用。我们想要判断如何调整W000才能让损失函数的结果降到最小,那么就需要基于损失函数来针对W000求导。问题在于W000并不是直接作用于损失函数,而是通过层层迭代传递来影响损失函数。W000先促进Z10也就是第二层第一个神经元输入信号的数值的创建(Z10中的1表示神经网络第2层,由于网络层的下标计数以0开始,最开始那层为第0层,所以1表示第二层网络层,其中的0表示第一层中第1个神经元),然后第二层第一个神经元将输入信号数值经过激活函数sigmoid转换后成为它的输出信号值a10,最后这个值参与了损失函数的计算。
将上面前向传播的链路转换为数学公式如下:

上面公式中有:

它对应的就是激活函数sigmod,然后const对应的就是b00,也就是调整常量。在使用反向传播时,首先针对最外层的a10,也就是第二层第一个神经元的输出信号进行求导:

由于a10是第一层两个神经元的输出信号经过对应链路抵达第二层神经元的信号加总后所得的z10经过激活函数sigmoid后所得的结果,因此a10是z10函数的结果,于是针对z10在基于sigmoid函数上进行求导:

最后Z10的形成有一部分是第一层第一个神经元输出的信号a00经过链路权重W00后贡献的(第一层第二个神经元输出的信号a01也通过链路权重W01进行了贡献,只是我们只查看W00的偏导,所以可以忽略它).于是z10又是W00的函数结果,于是基于z10的基础上针对w00进行求导:

最后我们把这三部分相乘就得到损失函数针对参数w00这点取值上的导数:

上面结果就是损失函数针对参数w00处切线的方向,要想通过调整w00来降低损失函数的结果,那么就需要根据这个方向反向调整w00的数值,这里的"反向调整"里面的文章大了去了,也是后续我们需要特别深入详解的地方,在这里我们先简单的认为反向调整就是乘以一个预先固定的参数,也叫学习率即可,最后我们看反向传播的代码实现:
js
print("==================== 反向传播 ====================")
# 1. 输出层误差 δ2
# dL/da2 = (a2 - Y) (因为MSE导数)
dL_da2 = (a2 - Y_true.T) # shape (2, 2)
# f'(z2) = sigmoid_derivative(a2)
delta2 = dL_da2 * sigmoid_derivative(a2) # shape (2, 2)
print(f"δ2 (输出层误差, dL/dz2):\n{delta2}\n")
# 2. 输出层参数梯度 dW2, db2
# dW2 = δ2 * a1^T
dW2 = np.dot(delta2, a1.T) # shape (2, 2)
# db2 = sum(δ2, axis=1, keepdims=True) # 对样本维度求和
db2 = np.sum(delta2, axis=1, keepdims=True) # shape (2, 1)
print(f"dW2 (输出层权重梯度):\n{dW2}\n")
print(f"db2 (输出层偏置梯度):\n{db2}\n")
# 3. 隐藏层误差 δ1
# dL/da1 = W2^T * δ2
dL_da1 = np.dot(W2.T, delta2) # shape (2, 2)
# f'(z1) = sigmoid_derivative(a1)
delta1 = dL_da1 * sigmoid_derivative(a1) # shape (2, 2)
print(f"δ1 (隐藏层误差, dL/dz1):\n{delta1}\n")
# 4. 隐藏层参数梯度 dW1, db1
dW1 = np.dot(delta1, a0.T) # shape (2, 2)
db1 = np.sum(delta1, axis=1, keepdims=True) # shape (2, 1)
print(f"dW1 (隐藏层权重梯度):\n{dW1}\n")
print(f"db1 (隐藏层偏置梯度):\n{db1}\n")
# ===================== 参数更新(演示) =====================
learning_rate = 0.1
W1_new = W1 - learning_rate * dW1
b1_new = b1 - learning_rate * db1
W2_new = W2 - learning_rate * dW2
b2_new = b2 - learning_rate * db2
print("==================== 参数更新 ====================")
print("更新后的 W1:\n", W1_new)
print("更新后的 b1:\n", b1_new)
print("更新后的 W2:\n", W2_new)
print("更新后的 b2:\n", b2_new)
可以仔细对应上面代码和前面的逻辑分析就能看到每一步实现都对应前面描述的链式法则。上面代码运行后给出的结果如下:
js
==================== 反向传播 ====================
δ2 (输出层误差, dL/dz2):
[[-0.04982891 -0.00131158]
[ 0.0622322 0.01426064]]
dW2 (输出层权重梯度):
[[-0.02536017 -0.0291319 ]
[ 0.03759731 0.044408 ]]
db2 (输出层偏置梯度):
[[-0.05114049]
[ 0.07649285]]
δ1 (隐藏层误差, dL/dz1):
[[-0.01348773 -0.00109167]
[-0.00054915 0.00077933]]
dW1 (隐藏层权重梯度):
[[-0.0069622 -0.00491965]
[-0.00011871 0.00045872]]
db1 (隐藏层偏置梯度):
[[-0.0145794 ]
[ 0.00023019]]
==================== 参数更新 ====================
更新后的 W1:
[[ 0.2490533 -0.06864019]
[ 0.32385614 0.76146906]]
更新后的 b1:
[[-0.11561875]
[-0.1170915 ]]
更新后的 W2:
[[ 0.79214242 0.38663055]
[-0.23849692 0.26683922]]
更新后的 b2:
[[-0.2265948 ]
[-0.24051416]]